diff --git a/.junie/guidelines.md b/.junie/guidelines.md
index 93dab41..a6c42d1 100644
--- a/.junie/guidelines.md
+++ b/.junie/guidelines.md
@@ -17,6 +17,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
- livewire/flux (FLUXUI_FREE) - v2
- livewire/flux-pro (FLUXUI_PRO) - v2
- livewire/livewire (LIVEWIRE) - v4
+- livewire/volt (VOLT) - v1
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
- pestphp/pest (PEST) - v3
@@ -267,6 +268,132 @@ accordion, autocomplete, avatar, badge, brand, breadcrumbs, button, calendar, ca
->assertSeeLivewire(CreatePost::class);
+=== volt/core rules ===
+
+## Livewire Volt
+
+- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt.
+- Make new Volt components using `vendor/bin/sail artisan make:volt [name] [--test] [--pest]`.
+- Volt is a class-based and functional API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to coexist in the same file.
+- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@volt` directive.
+- You must check existing Volt components to determine if they're functional or class-based. If you can't detect that, ask the user which they prefer before writing a Volt component.
+
+### Volt Functional Component Example
+
+
+@volt
+ 0]);
+
+$increment = fn () => $this->count++;
+$decrement = fn () => $this->count--;
+
+$double = computed(fn () => $this->count * 2);
+?>
+
+
+
Count: {{ $count }}
+ Double: {{ $this->double }}
+ +
+ -
+
+@endvolt
+
+
+### Volt Class Based Component Example
+To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax:
+
+
+use Livewire\Volt\Component;
+
+new class extends Component {
+ public $count = 0;
+
+ public function increment()
+ {
+ $this->count++;
+ }
+} ?>
+
+
+
{{ $count }}
+ +
+
+
+
+### Testing Volt & Volt Components
+- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
+
+
+use Livewire\Volt\Volt;
+
+test('counter increments', function () {
+ Volt::test('counter')
+ ->assertSee('Count: 0')
+ ->call('increment')
+ ->assertSee('Count: 1');
+});
+
+
+
+declare(strict_types=1);
+
+use App\Models\{User, Product};
+use Livewire\Volt\Volt;
+
+test('product form creates product', function () {
+ $user = User::factory()->create();
+
+ Volt::test('pages.products.create')
+ ->actingAs($user)
+ ->set('form.name', 'Test Product')
+ ->set('form.description', 'Test Description')
+ ->set('form.price', 99.99)
+ ->call('create')
+ ->assertHasNoErrors();
+
+ expect(Product::where('name', 'Test Product')->exists())->toBeTrue();
+});
+
+
+### Common Patterns
+
+
+ null, 'search' => '']);
+
+$products = computed(fn() => Product::when($this->search,
+ fn($q) => $q->where('name', 'like', "%{$this->search}%")
+)->get());
+
+$edit = fn(Product $product) => $this->editing = $product->id;
+$delete = fn(Product $product) => $product->delete();
+
+?>
+
+
+
+
+
+
+
+
+
+
+ Save
+ Saving...
+
+
+
=== pint/core rules ===
## Laravel Pint Code Formatter
diff --git a/AGENTS.md b/AGENTS.md
index 93dab41..a6c42d1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -17,6 +17,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
- livewire/flux (FLUXUI_FREE) - v2
- livewire/flux-pro (FLUXUI_PRO) - v2
- livewire/livewire (LIVEWIRE) - v4
+- livewire/volt (VOLT) - v1
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
- pestphp/pest (PEST) - v3
@@ -267,6 +268,132 @@ accordion, autocomplete, avatar, badge, brand, breadcrumbs, button, calendar, ca
->assertSeeLivewire(CreatePost::class);
+=== volt/core rules ===
+
+## Livewire Volt
+
+- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt.
+- Make new Volt components using `vendor/bin/sail artisan make:volt [name] [--test] [--pest]`.
+- Volt is a class-based and functional API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to coexist in the same file.
+- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@volt` directive.
+- You must check existing Volt components to determine if they're functional or class-based. If you can't detect that, ask the user which they prefer before writing a Volt component.
+
+### Volt Functional Component Example
+
+
+@volt
+ 0]);
+
+$increment = fn () => $this->count++;
+$decrement = fn () => $this->count--;
+
+$double = computed(fn () => $this->count * 2);
+?>
+
+
+
Count: {{ $count }}
+ Double: {{ $this->double }}
+ +
+ -
+
+@endvolt
+
+
+### Volt Class Based Component Example
+To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax:
+
+
+use Livewire\Volt\Component;
+
+new class extends Component {
+ public $count = 0;
+
+ public function increment()
+ {
+ $this->count++;
+ }
+} ?>
+
+
+
{{ $count }}
+ +
+
+
+
+### Testing Volt & Volt Components
+- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
+
+
+use Livewire\Volt\Volt;
+
+test('counter increments', function () {
+ Volt::test('counter')
+ ->assertSee('Count: 0')
+ ->call('increment')
+ ->assertSee('Count: 1');
+});
+
+
+
+declare(strict_types=1);
+
+use App\Models\{User, Product};
+use Livewire\Volt\Volt;
+
+test('product form creates product', function () {
+ $user = User::factory()->create();
+
+ Volt::test('pages.products.create')
+ ->actingAs($user)
+ ->set('form.name', 'Test Product')
+ ->set('form.description', 'Test Description')
+ ->set('form.price', 99.99)
+ ->call('create')
+ ->assertHasNoErrors();
+
+ expect(Product::where('name', 'Test Product')->exists())->toBeTrue();
+});
+
+
+### Common Patterns
+
+
+ null, 'search' => '']);
+
+$products = computed(fn() => Product::when($this->search,
+ fn($q) => $q->where('name', 'like', "%{$this->search}%")
+)->get());
+
+$edit = fn(Product $product) => $this->editing = $product->id;
+$delete = fn(Product $product) => $product->delete();
+
+?>
+
+
+
+
+
+
+
+
+
+
+ Save
+ Saving...
+
+
+
=== pint/core rules ===
## Laravel Pint Code Formatter
diff --git a/CLAUDE.md b/CLAUDE.md
index 93dab41..a6c42d1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -17,6 +17,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
- livewire/flux (FLUXUI_FREE) - v2
- livewire/flux-pro (FLUXUI_PRO) - v2
- livewire/livewire (LIVEWIRE) - v4
+- livewire/volt (VOLT) - v1
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
- pestphp/pest (PEST) - v3
@@ -267,6 +268,132 @@ accordion, autocomplete, avatar, badge, brand, breadcrumbs, button, calendar, ca
->assertSeeLivewire(CreatePost::class);
+=== volt/core rules ===
+
+## Livewire Volt
+
+- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt.
+- Make new Volt components using `vendor/bin/sail artisan make:volt [name] [--test] [--pest]`.
+- Volt is a class-based and functional API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to coexist in the same file.
+- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@volt` directive.
+- You must check existing Volt components to determine if they're functional or class-based. If you can't detect that, ask the user which they prefer before writing a Volt component.
+
+### Volt Functional Component Example
+
+
+@volt
+ 0]);
+
+$increment = fn () => $this->count++;
+$decrement = fn () => $this->count--;
+
+$double = computed(fn () => $this->count * 2);
+?>
+
+
+
Count: {{ $count }}
+ Double: {{ $this->double }}
+ +
+ -
+
+@endvolt
+
+
+### Volt Class Based Component Example
+To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax:
+
+
+use Livewire\Volt\Component;
+
+new class extends Component {
+ public $count = 0;
+
+ public function increment()
+ {
+ $this->count++;
+ }
+} ?>
+
+
+
{{ $count }}
+ +
+
+
+
+### Testing Volt & Volt Components
+- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
+
+
+use Livewire\Volt\Volt;
+
+test('counter increments', function () {
+ Volt::test('counter')
+ ->assertSee('Count: 0')
+ ->call('increment')
+ ->assertSee('Count: 1');
+});
+
+
+
+declare(strict_types=1);
+
+use App\Models\{User, Product};
+use Livewire\Volt\Volt;
+
+test('product form creates product', function () {
+ $user = User::factory()->create();
+
+ Volt::test('pages.products.create')
+ ->actingAs($user)
+ ->set('form.name', 'Test Product')
+ ->set('form.description', 'Test Description')
+ ->set('form.price', 99.99)
+ ->call('create')
+ ->assertHasNoErrors();
+
+ expect(Product::where('name', 'Test Product')->exists())->toBeTrue();
+});
+
+
+### Common Patterns
+
+
+ null, 'search' => '']);
+
+$products = computed(fn() => Product::when($this->search,
+ fn($q) => $q->where('name', 'like', "%{$this->search}%")
+)->get());
+
+$edit = fn(Product $product) => $this->editing = $product->id;
+$delete = fn(Product $product) => $product->delete();
+
+?>
+
+
+
+
+
+
+
+
+
+
+ Save
+ Saving...
+
+
+
=== pint/core rules ===
## Laravel Pint Code Formatter
diff --git a/app/Models/EinundzwanzigPleb.php b/app/Models/EinundzwanzigPleb.php
index f5a53a2..fefbdcd 100644
--- a/app/Models/EinundzwanzigPleb.php
+++ b/app/Models/EinundzwanzigPleb.php
@@ -3,14 +3,16 @@
namespace App\Models;
use App\Enums\AssociationStatus;
-use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Foundation\Auth\User as Authenticatable;
use ParagonIE\CipherSweet\BlindIndex;
use ParagonIE\CipherSweet\EncryptedRow;
use Spatie\LaravelCipherSweet\Concerns\UsesCipherSweet;
use Spatie\LaravelCipherSweet\Contracts\CipherSweetEncrypted;
-class EinundzwanzigPleb extends Model implements CipherSweetEncrypted
+class EinundzwanzigPleb extends Authenticatable implements CipherSweetEncrypted
{
+ use HasFactory;
use UsesCipherSweet;
protected $guarded = [];
diff --git a/database/migrations/2024_08_29_190127_create_pulse_tables.php b/backup_migrations/2024_08_29_190127_create_pulse_tables.php
similarity index 100%
rename from database/migrations/2024_08_29_190127_create_pulse_tables.php
rename to backup_migrations/2024_08_29_190127_create_pulse_tables.php
diff --git a/composer.json b/composer.json
index dce247b..2739624 100644
--- a/composer.json
+++ b/composer.json
@@ -20,6 +20,7 @@
"livewire/flux": "^2.10",
"livewire/flux-pro": "^2.10",
"livewire/livewire": "^4.0",
+ "livewire/volt": "^1.0",
"openspout/openspout": "^4.24",
"power-components/livewire-powergrid": "^6.7",
"pusher/pusher-php-server": "^7.2.2",
diff --git a/composer.lock b/composer.lock
index cbadb66..0777bba 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "39a9ce519dbfeb237966b7441b59a562",
+ "content-hash": "7a60c8e828d100018e8703dd85753739",
"packages": [
{
"name": "akuechler/laravel-geoly",
@@ -3240,6 +3240,77 @@
],
"time": "2026-01-14T18:40:41+00:00"
},
+ {
+ "name": "livewire/volt",
+ "version": "v1.10.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/livewire/volt.git",
+ "reference": "48cff133990c6261c63ee279fc091af6f6c6654e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/livewire/volt/zipball/48cff133990c6261c63ee279fc091af6f6c6654e",
+ "reference": "48cff133990c6261c63ee279fc091af6f6c6654e",
+ "shasum": ""
+ },
+ "require": {
+ "laravel/framework": "^10.38.2|^11.0|^12.0",
+ "livewire/livewire": "^3.6.1|^4.0",
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "laravel/folio": "^1.1",
+ "orchestra/testbench": "^8.36|^9.15|^10.8",
+ "pestphp/pest": "^2.9.5|^3.0|^4.0",
+ "phpstan/phpstan": "^1.10"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Livewire\\Volt\\VoltServiceProvider"
+ ]
+ },
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "functions.php"
+ ],
+ "psr-4": {
+ "Livewire\\Volt\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ },
+ {
+ "name": "Nuno Maduro",
+ "email": "nuno@laravel.com"
+ }
+ ],
+ "description": "An elegantly crafted functional API for Laravel Livewire.",
+ "homepage": "https://github.com/livewire/volt",
+ "keywords": [
+ "laravel",
+ "livewire",
+ "volt"
+ ],
+ "support": {
+ "issues": "https://github.com/livewire/volt/issues",
+ "source": "https://github.com/livewire/volt"
+ },
+ "time": "2025-11-25T16:19:15+00:00"
+ },
{
"name": "maennchen/zipstream-php",
"version": "3.2.1",
diff --git a/database/factories/EinundzwanzigPlebFactory.php b/database/factories/EinundzwanzigPlebFactory.php
new file mode 100644
index 0000000..e7f2114
--- /dev/null
+++ b/database/factories/EinundzwanzigPlebFactory.php
@@ -0,0 +1,26 @@
+
+ */
+class EinundzwanzigPlebFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'pubkey' => $this->faker->sha256(),
+ 'npub' => $this->faker->word(),
+ 'email' => $this->faker->safeEmail(),
+ 'association_status' => \App\Enums\AssociationStatus::DEFAULT,
+ ];
+ }
+}
diff --git a/database/migrations/2024_08_29_120928_create_einundzwanzig_plebs_table.php b/database/migrations/2023_03_10_190259_create_einundzwanzig_plebs_table.php
similarity index 100%
rename from database/migrations/2024_08_29_120928_create_einundzwanzig_plebs_table.php
rename to database/migrations/2023_03_10_190259_create_einundzwanzig_plebs_table.php
diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php
index 8b5843f..75a170f 100644
--- a/tests/Feature/ExampleTest.php
+++ b/tests/Feature/ExampleTest.php
@@ -1,7 +1,8 @@
get('/');
+use Livewire\Livewire;
- $response->assertStatus(200);
+it('returns a successful response', function () {
+ Livewire::test('association.profile')
+ ->assertStatus(200);
});
diff --git a/tests/Feature/Livewire/Association/ElectionTest.php b/tests/Feature/Livewire/Association/ElectionTest.php
new file mode 100644
index 0000000..cea3345
--- /dev/null
+++ b/tests/Feature/Livewire/Association/ElectionTest.php
@@ -0,0 +1,145 @@
+create(['year' => 2024]);
+ $election2 = Election::factory()->create(['year' => 2025]);
+
+ Livewire::test('association.election.index')
+ ->assertSet('elections', function ($elections) {
+ return count($elections) >= 2;
+ });
+});
+
+it('denies access to unauthorized users in election index', function () {
+ $pleb = EinundzwanzigPleb::factory()->create();
+ $election = Election::factory()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.election.index', ['election' => $election])
+ ->assertSet('isAllowed', false);
+});
+
+it('grants access to authorized users in election index', function () {
+ $allowedPubkey = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033';
+ $pleb = EinundzwanzigPleb::factory()->create(['pubkey' => $allowedPubkey]);
+ $election = Election::factory()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.election.index', ['election' => $election])
+ ->assertSet('isAllowed', true);
+});
+
+// Election Admin Tests
+it('renders election admin component', function () {
+ $election = Election::factory()->create();
+
+ Livewire::test('association.election.admin', ['election' => $election])
+ ->assertStatus(200);
+});
+
+it('denies access to unauthorized users in election admin', function () {
+ $pleb = EinundzwanzigPleb::factory()->create();
+ $election = Election::factory()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.election.admin', ['election' => $election])
+ ->assertSet('isAllowed', false);
+});
+
+it('grants access to authorized users in election admin', function () {
+ $allowedPubkey = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033';
+ $pleb = EinundzwanzigPleb::factory()->create(['pubkey' => $allowedPubkey]);
+ $election = Election::factory()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.election.admin', ['election' => $election])
+ ->assertSet('isAllowed', true);
+});
+
+it('can save election candidates', function () {
+ $allowedPubkey = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033';
+ $pleb = EinundzwanzigPleb::factory()->create(['pubkey' => $allowedPubkey]);
+ $election = Election::factory()->create([
+ 'candidates' => json_encode([['type' => 'presidency', 'c' => []]]),
+ ]);
+
+ NostrAuth::login($pleb->pubkey);
+
+ $newCandidates = json_encode([['type' => 'presidency', 'c' => ['test-pubkey']]]);
+
+ Livewire::test('association.election.admin', ['election' => $election])
+ ->set('elections.0.candidates', $newCandidates)
+ ->call('saveElection', 0);
+
+ expect($election->fresh()->candidates)->toBe($newCandidates);
+});
+
+// Election Show Tests
+it('renders election show component', function () {
+ $election = Election::factory()->create();
+
+ Livewire::test('association.election.show', ['election' => $election])
+ ->assertStatus(200);
+});
+
+it('loads election data on mount in show', function () {
+ $election = Election::factory()->create();
+
+ Livewire::test('association.election.show', ['election' => $election])
+ ->assertSet('election.id', $election->id);
+});
+
+it('handles search in election show', function () {
+ $election = Election::factory()->create();
+ $pleb1 = EinundzwanzigPleb::factory()->active()->create();
+ $pleb2 = EinundzwanzigPleb::factory()->boardMember()->create();
+
+ Livewire::test('association.election.show', ['election' => $election])
+ ->set('search', $pleb1->pubkey)
+ ->assertSet('plebs', function ($plebs) use ($pleb1) {
+ return collect($plebs)->contains('pubkey', $pleb1->pubkey);
+ });
+});
+
+it('can create vote event', function () {
+ $election = Election::factory()->create();
+ $pleb = EinundzwanzigPleb::factory()->active()->create();
+ $candidatePubkey = 'test-candidate-pubkey';
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.election.show', ['election' => $election])
+ ->call('vote', $candidatePubkey, 'presidency', false)
+ ->assertSet('signThisEvent', function ($event) use ($candidatePubkey) {
+ return str_contains($event, $candidatePubkey);
+ });
+});
+
+it('checks election closure status', function () {
+ $election = Election::factory()->create([
+ 'end_time' => now()->subDay(),
+ ]);
+
+ Livewire::test('association.election.show', ['election' => $election])
+ ->call('checkElection')
+ ->assertSet('isNotClosed', false);
+});
+
+it('displays log for authorized users', function () {
+ $allowedPubkey = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033';
+ $pleb = EinundzwanzigPleb::factory()->create(['pubkey' => $allowedPubkey]);
+ $election = Election::factory()->create();
+
+ Livewire::test('association.election.show', ['election' => $election])
+ ->call('handleNostrLoggedIn', $allowedPubkey)
+ ->assertSet('showLog', true);
+});
diff --git a/tests/Feature/Livewire/Association/Members/AdminTest.php b/tests/Feature/Livewire/Association/Members/AdminTest.php
new file mode 100644
index 0000000..5ec4132
--- /dev/null
+++ b/tests/Feature/Livewire/Association/Members/AdminTest.php
@@ -0,0 +1,73 @@
+create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.members.admin')
+ ->assertSet('isAllowed', false)
+ ->assertSee('Du bist nicht berechtigt, Mitglieder zu bearbeiten.');
+});
+
+it('grants access to authorized pubkeys', function () {
+ $allowedPubkeys = [
+ '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033',
+ '430169631f2f0682c60cebb4f902d68f0c71c498fd1711fd982f052cf1fd4279',
+ '7acf30cf60b85c62b8f654556cc21e4016df8f5604b3b6892794f88bb80d7a1d',
+ 'f240be2b684f85cc81566f2081386af81d7427ea86250c8bde6b7a8500c761ba',
+ '19e358b8011f5f4fc653c565c6d4c2f33f32661f4f90982c9eedc292a8774ec3',
+ 'acbcec475a1a4f9481939ecfbd1c3d111f5b5a474a39ae039bbc720fdd305bec',
+ ];
+
+ $pleb = EinundzwanzigPleb::factory()->create([
+ 'pubkey' => $allowedPubkeys[0],
+ ]);
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.members.admin')
+ ->assertSet('isAllowed', true);
+});
+
+it('handles nostr login for authorized user', function () {
+ $allowedPubkey = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033';
+ $pleb = EinundzwanzigPleb::factory()->create([
+ 'pubkey' => $allowedPubkey,
+ ]);
+
+ Livewire::test('association.members.admin')
+ ->call('handleNostrLoggedIn', $allowedPubkey)
+ ->assertSet('isAllowed', true)
+ ->assertSet('currentPubkey', $allowedPubkey);
+});
+
+it('handles nostr logout', function () {
+ $allowedPubkey = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033';
+ $pleb = EinundzwanzigPleb::factory()->create([
+ 'pubkey' => $allowedPubkey,
+ ]);
+
+ Livewire::test('association.members.admin')
+ ->call('handleNostrLoggedIn', $allowedPubkey)
+ ->call('handleNostrLoggedOut')
+ ->assertSet('isAllowed', false)
+ ->assertSet('currentPubkey', null);
+});
+
+it('displays einundzwanzig pleb table when authorized', function () {
+ $allowedPubkey = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033';
+ $pleb = EinundzwanzigPleb::factory()->create([
+ 'pubkey' => $allowedPubkey,
+ ]);
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.members.admin')
+ ->assertSet('isAllowed', true)
+ ->assertSee('einundzwanzig-pleb-table');
+});
diff --git a/tests/Feature/Livewire/Association/NewsTest.php b/tests/Feature/Livewire/Association/NewsTest.php
new file mode 100644
index 0000000..e5f9b23
--- /dev/null
+++ b/tests/Feature/Livewire/Association/NewsTest.php
@@ -0,0 +1,110 @@
+create([
+ 'association_status' => AssociationStatus::PASSIVE,
+ ]);
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.news')
+ ->assertSet('isAllowed', false);
+});
+
+it('denies access when pleb has not paid for current year', function () {
+ $pleb = EinundzwanzigPleb::factory()->create([
+ 'association_status' => AssociationStatus::ACTIVE,
+ ]);
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.news')
+ ->assertSet('isAllowed', false);
+});
+
+it('grants access when pleb is active and has paid', function () {
+ $pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.news')
+ ->assertSet('isAllowed', true);
+});
+
+it('allows board member to edit news', function () {
+ $pleb = EinundzwanzigPleb::factory()->boardMember()->withPaidCurrentYear()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.news')
+ ->assertSet('canEdit', true);
+});
+
+it('can create news entry with pdf', function () {
+ $pleb = EinundzwanzigPleb::factory()->boardMember()->withPaidCurrentYear()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ $file = UploadedFile::fake()->create('document.pdf', 100);
+
+ Livewire::test('association.news')
+ ->set('file', $file)
+ ->set('form.category', NewsCategory::ORGANISATION->value)
+ ->set('form.name', 'Test News')
+ ->set('form.description', 'Test Description')
+ ->call('save')
+ ->assertHasNoErrors();
+
+ expect(Notification::where('name', 'Test News')->exists())->toBeTrue();
+});
+
+it('validates news entry creation', function () {
+ $pleb = EinundzwanzigPleb::factory()->boardMember()->withPaidCurrentYear()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.news')
+ ->call('save')
+ ->assertHasErrors(['file', 'form.category', 'form.name']);
+});
+
+it('can delete news entry', function () {
+ $pleb = EinundzwanzigPleb::factory()->boardMember()->withPaidCurrentYear()->create();
+ $news = Notification::factory()->create([
+ 'einundzwanzig_pleb_id' => $pleb->id,
+ ]);
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.news')
+ ->call('delete', $news->id)
+ ->assertHasNoErrors();
+
+ expect(Notification::find($news->id))->toBeNull();
+});
+
+it('displays news list', function () {
+ $pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
+ $news1 = Notification::factory()->create();
+ $news2 = Notification::factory()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.news')
+ ->assertSet('isAllowed', true)
+ ->assertSee($news1->name)
+ ->assertSee($news2->name);
+});
diff --git a/tests/Feature/Livewire/Association/ProfileTest.php b/tests/Feature/Livewire/Association/ProfileTest.php
new file mode 100644
index 0000000..3eaa90d
--- /dev/null
+++ b/tests/Feature/Livewire/Association/ProfileTest.php
@@ -0,0 +1,116 @@
+create();
+
+ Livewire::test('association.profile')
+ ->call('handleNostrLoggedIn', $pleb->pubkey)
+ ->assertSet('currentPubkey', $pleb->pubkey)
+ ->assertSet('currentPleb.pubkey', $pleb->pubkey);
+});
+
+it('handles nostr logout correctly', function () {
+ $pleb = EinundzwanzigPleb::factory()->create();
+
+ Livewire::test('association.profile')
+ ->call('handleNostrLoggedIn', $pleb->pubkey)
+ ->call('handleNostrLoggedOut')
+ ->assertSet('currentPubkey', null)
+ ->assertSet('currentPleb', null);
+});
+
+it('can save email address', function () {
+ $pleb = EinundzwanzigPleb::factory()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.profile')
+ ->set('email', 'test@example.com')
+ ->call('saveEmail')
+ ->assertHasNoErrors();
+
+ expect($pleb->fresh()->email)->toBe('test@example.com');
+});
+
+it('validates email format', function () {
+ $pleb = EinundzwanzigPleb::factory()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.profile')
+ ->set('email', 'invalid-email')
+ ->call('saveEmail')
+ ->assertHasErrors(['email']);
+});
+
+it('can update no email preference', function () {
+ $pleb = EinundzwanzigPleb::factory()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.profile')
+ ->set('no', true)
+ ->assertSet('showEmail', false);
+
+ expect($pleb->fresh()->no_email)->toBeTrue();
+});
+
+it('can save membership application', function () {
+ $pleb = EinundzwanzigPleb::factory()->create([
+ 'association_status' => AssociationStatus::DEFAULT,
+ ]);
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.profile')
+ ->set('form.check', true)
+ ->call('save', AssociationStatus::PASSIVE->value)
+ ->assertHasNoErrors();
+
+ expect($pleb->fresh()->association_status)->toBe(AssociationStatus::PASSIVE);
+});
+
+it('creates payment event when pleb becomes active', function () {
+ $pleb = EinundzwanzigPleb::factory()->active()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.profile')
+ ->assertSet('amountToPay', config('app.env') === 'production' ? 21000 : 1);
+
+ expect($pleb->paymentEvents()->count())->toBeGreaterThan(0);
+});
+
+it('displays paid status for current year', function () {
+ $pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.profile')
+ ->call('listenForPayment')
+ ->assertSet('currentYearIsPaid', true);
+});
+
+it('can initiate payment', function () {
+ Http::fake([
+ 'pay.einundzwanzig.space/*' => Http::response([
+ 'id' => 'invoice123',
+ 'checkoutLink' => 'https://pay.einundzwanzig.space/checkout/invoice123',
+ ], 200),
+ ]);
+
+ $pleb = EinundzwanzigPleb::factory()->active()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ $response = Livewire::test('association.profile')
+ ->call('pay', 'test-comment');
+
+ $response->assertRedirect();
+});
diff --git a/tests/Feature/Livewire/Association/ProjectSupportTest.php b/tests/Feature/Livewire/Association/ProjectSupportTest.php
new file mode 100644
index 0000000..761a683
--- /dev/null
+++ b/tests/Feature/Livewire/Association/ProjectSupportTest.php
@@ -0,0 +1,228 @@
+create();
+ $project2 = ProjectProposal::factory()->create();
+
+ Livewire::test('association.project-support.index')
+ ->assertSet('projects', function ($projects) {
+ return $projects->count() >= 2;
+ });
+});
+
+it('can search projects', function () {
+ $project = ProjectProposal::factory()->create(['name' => 'Unique Project Name']);
+
+ Livewire::test('association.project-support.index')
+ ->set('search', 'Unique')
+ ->assertSet('projects', function ($projects) use ($project) {
+ return $projects->contains('id', $project->id);
+ });
+});
+
+it('can filter projects', function () {
+ Livewire::test('association.project-support.index')
+ ->call('setFilter', 'new')
+ ->assertSet('activeFilter', 'new');
+});
+
+it('can confirm delete', function () {
+ $project = ProjectProposal::factory()->create();
+
+ Livewire::test('association.project-support.index')
+ ->call('confirmDelete', $project->id)
+ ->assertSet('confirmDeleteId', $project->id);
+});
+
+it('can delete project', function () {
+ $pleb = EinundzwanzigPleb::factory()->boardMember()->create();
+ $project = ProjectProposal::factory()->create([
+ 'einundzwanzig_pleb_id' => $pleb->id,
+ ]);
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.project-support.index')
+ ->set('confirmDeleteId', $project->id)
+ ->call('delete');
+
+ expect(ProjectProposal::find($project->id))->toBeNull();
+});
+
+it('handles nostr login', function () {
+ $pleb = EinundzwanzigPleb::factory()->create();
+
+ Livewire::test('association.project-support.index')
+ ->call('handleNostrLoggedIn', $pleb->pubkey)
+ ->assertSet('currentPubkey', $pleb->pubkey)
+ ->assertSet('isAllowed', true);
+});
+
+it('handles nostr logout', function () {
+ $pleb = EinundzwanzigPleb::factory()->create();
+
+ Livewire::test('association.project-support.index')
+ ->call('handleNostrLoggedIn', $pleb->pubkey)
+ ->call('handleNostrLoggedOut')
+ ->assertSet('currentPubkey', null)
+ ->assertSet('isAllowed', false);
+});
+
+it('denies access to create when not authenticated', function () {
+ Livewire::test('association.project-support.form.create')
+ ->assertSet('isAllowed', false)
+ ->assertSee('Du bist nicht berechtigt, eine Projektförderung anzulegen.');
+});
+
+it('denies access to create when pleb has not paid', function () {
+ $pleb = EinundzwanzigPleb::factory()->active()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.project-support.form.create')
+ ->assertSet('isAllowed', false);
+});
+
+it('grants access to create when pleb is active and paid', function () {
+ $pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.project-support.form.create')
+ ->assertSet('isAllowed', true);
+});
+
+it('can create project proposal', function () {
+ $pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.project-support.form.create')
+ ->set('form.name', 'Test Project')
+ ->set('form.description', 'Test Description')
+ ->call('save')
+ ->assertHasNoErrors();
+
+ expect(ProjectProposal::where('name', 'Test Project')->exists())->toBeTrue();
+});
+
+it('validates project proposal creation', function () {
+ $pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.project-support.form.create')
+ ->call('save')
+ ->assertHasErrors(['form.name', 'form.description']);
+});
+
+// Project Support Edit Tests
+it('renders project support edit component', function () {
+ $pleb = EinundzwanzigPleb::factory()->create();
+ $project = ProjectProposal::factory()->create([
+ 'einundzwanzig_pleb_id' => $pleb->id,
+ ]);
+
+ Livewire::test('association.project-support.form.edit', ['project' => $project])
+ ->assertStatus(200);
+});
+
+it('denies access to edit when not owner', function () {
+ $pleb = EinundzwanzigPleb::factory()->create();
+ $project = ProjectProposal::factory()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.project-support.form.edit', ['project' => $project])
+ ->assertSet('isAllowed', false);
+});
+
+it('grants access to edit when owner', function () {
+ $pleb = EinundzwanzigPleb::factory()->create();
+ $project = ProjectProposal::factory()->create([
+ 'einundzwanzig_pleb_id' => $pleb->id,
+ ]);
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.project-support.form.edit', ['project' => $project])
+ ->assertSet('isAllowed', true);
+});
+
+it('can update project proposal', function () {
+ $pleb = EinundzwanzigPleb::factory()->create();
+ $project = ProjectProposal::factory()->create([
+ 'einundzwanzig_pleb_id' => $pleb->id,
+ 'name' => 'Old Name',
+ ]);
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.project-support.form.edit', ['project' => $project])
+ ->set('form.name', 'New Name')
+ ->set('form.description', 'Updated Description')
+ ->call('update')
+ ->assertHasNoErrors();
+
+ expect($project->fresh()->name)->toBe('New Name');
+});
+
+it('validates project proposal update', function () {
+ $pleb = EinundzwanzigPleb::factory()->create();
+ $project = ProjectProposal::factory()->create([
+ 'einundzwanzig_pleb_id' => $pleb->id,
+ ]);
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.project-support.form.edit', ['project' => $project])
+ ->set('form.name', '')
+ ->call('update')
+ ->assertHasErrors(['form.name']);
+});
+
+// Project Support Show Tests
+it('renders project support show component', function () {
+ $project = ProjectProposal::factory()->create();
+
+ Livewire::test('association.project-support.show', ['project' => $project])
+ ->assertStatus(200);
+});
+
+it('denies access to show when not authenticated', function () {
+ $project = ProjectProposal::factory()->create();
+
+ Livewire::test('association.project-support.show', ['project' => $project])
+ ->assertSet('isAllowed', false)
+ ->assertSee('Du bist nicht berechtigt, die Projektförderung einzusehen.');
+});
+
+it('grants access to show when authenticated', function () {
+ $pleb = EinundzwanzigPleb::factory()->create();
+ $project = ProjectProposal::factory()->create();
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.project-support.show', ['project' => $project])
+ ->assertSet('isAllowed', true);
+});
+
+it('displays project details', function () {
+ $pleb = EinundzwanzigPleb::factory()->create();
+ $project = ProjectProposal::factory()->create([
+ 'name' => 'Test Project Name',
+ 'description' => 'Test Project Description',
+ ]);
+
+ NostrAuth::login($pleb->pubkey);
+
+ Livewire::test('association.project-support.show', ['project' => $project])
+ ->assertSet('project.name', 'Test Project Name')
+ ->assertSee('Test Project Name')
+ ->assertSee('Test Project Description');
+});
diff --git a/tests/Pest.php b/tests/Pest.php
index 50ab1e4..3d4d875 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -13,7 +13,7 @@
uses(
Tests\TestCase::class,
- // Illuminate\Foundation\Testing\RefreshDatabase::class,
+ Illuminate\Foundation\Testing\RefreshDatabase::class,
)->in('Feature');
/*