🔥 **Cleanup:** Removed BookCase and OrangePill models, factories, migrations, and related references. Added tests for new service and meetup creation flows. Updated PHPUnit settings and browser-specific configurations.

This commit is contained in:
BT
2026-05-02 22:00:26 +01:00
parent 63aed880e1
commit 04e3e30fcf
54 changed files with 3440 additions and 298 deletions
+71
View File
@@ -0,0 +1,71 @@
<?php
use App\Models\Highscore;
it('returns all highscores ordered by satoshis desc on GET /api/highscores', function () {
Highscore::factory()->create(['satoshis' => 100, 'achieved_at' => now()->subHours(1)]);
Highscore::factory()->create(['satoshis' => 5000, 'achieved_at' => now()->subHours(2)]);
Highscore::factory()->create(['satoshis' => 1000, 'achieved_at' => now()->subHours(3)]);
$response = $this->getJson('/api/highscores');
$response->assertSuccessful();
$data = $response->json('data');
expect(collect($data)->pluck('satoshis')->all())->toBe([5000, 1000, 100]);
});
it('accepts a valid highscore submission', function () {
$payload = [
'npub' => 'npub1'.str_repeat('a', 58),
'name' => 'Tester',
'satoshis' => 1234,
'blocks' => 5,
'datetime' => now()->subDay()->toIso8601String(),
];
$this->postJson('/api/highscores', $payload)
->assertStatus(202)
->assertJsonPath('data.satoshis', 1234)
->assertJsonPath('data.name', 'Tester');
expect(Highscore::query()->where('npub', $payload['npub'])->exists())->toBeTrue();
});
it('rejects a highscore submission missing npub', function () {
$this->postJson('/api/highscores', [
'satoshis' => 1234,
'blocks' => 5,
'datetime' => now()->toIso8601String(),
])->assertUnprocessable()
->assertJsonValidationErrors(['npub']);
});
it('rejects a highscore submission with an npub that does not start with npub1', function () {
$this->postJson('/api/highscores', [
'npub' => 'nsec1'.str_repeat('a', 58),
'satoshis' => 1234,
'blocks' => 5,
'datetime' => now()->toIso8601String(),
])->assertUnprocessable()
->assertJsonValidationErrors(['npub']);
});
it('rejects a highscore submission with negative satoshis', function () {
$this->postJson('/api/highscores', [
'npub' => 'npub1'.str_repeat('b', 58),
'satoshis' => -10,
'blocks' => 5,
'datetime' => now()->toIso8601String(),
])->assertUnprocessable()
->assertJsonValidationErrors(['satoshis']);
});
it('rejects a highscore submission with an invalid datetime', function () {
$this->postJson('/api/highscores', [
'npub' => 'npub1'.str_repeat('c', 58),
'satoshis' => 100,
'blocks' => 5,
'datetime' => 'not-a-date',
])->assertUnprocessable()
->assertJsonValidationErrors(['datetime']);
});
+30
View File
@@ -0,0 +1,30 @@
<?php
use App\Models\LibraryItem;
use App\Models\User;
it('returns nostr-pubkeys in /api/nostrplebs', function () {
User::factory()->create(['nostr' => 'npub1'.str_repeat('a', 58)]);
User::factory()->create(['nostr' => 'npub1'.str_repeat('b', 58)]);
User::factory()->create(['nostr' => null]);
$response = $this->getJson('/api/nostrplebs');
$response->assertSuccessful();
expect($response->json())
->toHaveCount(2)
->each->toStartWith('npub1');
});
it('returns bindle-type library items in /api/bindles', function () {
LibraryItem::factory()->create(['type' => 'bindle', 'name' => 'My Bindle']);
LibraryItem::factory()->create(['type' => 'article', 'name' => 'My Article']);
$response = $this->getJson('/api/bindles');
$response->assertSuccessful();
$names = collect($response->json())->pluck('name');
expect($names->all())
->toContain('My Bindle')
->not->toContain('My Article');
});
+96
View File
@@ -0,0 +1,96 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Meetup;
use App\Models\MeetupEvent;
beforeEach(function () {
$country = Country::factory()->create(['code' => 'de']);
$this->city = City::factory()->create(['country_id' => $country->id]);
});
it('returns visible meetups in JSON shape on GET /api/meetups', function () {
Meetup::factory()->create([
'city_id' => $this->city->id,
'visible_on_map' => true,
'name' => 'Visible Meetup',
'community' => 'einundzwanzig',
]);
Meetup::factory()->create([
'city_id' => $this->city->id,
'visible_on_map' => false,
'name' => 'Hidden Meetup',
]);
$response = $this->getJson('/api/meetups');
$response->assertSuccessful();
$names = collect($response->json())->pluck('name');
expect($names->all())->toContain('Visible Meetup')
->not->toContain('Hidden Meetup');
});
it('includes intro and logo when ?withIntro=1&withLogos=1 is provided', function () {
Meetup::factory()->create([
'city_id' => $this->city->id,
'visible_on_map' => true,
'name' => 'WithExtras',
'intro' => 'Some intro text',
]);
$response = $this->getJson('/api/meetups?withIntro=1&withLogos=1');
$response->assertSuccessful();
$payload = collect($response->json())->firstWhere('name', 'WithExtras');
expect($payload)
->intro->toBe('Some intro text')
->logo->toBeString();
});
it('returns einundzwanzig community meetups in BTC-Map format', function () {
Meetup::factory()->create([
'city_id' => $this->city->id,
'community' => 'einundzwanzig',
'name' => 'BTC Map Meetup',
]);
Meetup::factory()->create([
'city_id' => $this->city->id,
'community' => 'other',
'name' => 'Excluded Meetup',
]);
$response = $this->getJson('/api/btc-map-communities');
$response->assertSuccessful()
->assertJsonStructure([['id', 'tags' => ['type', 'name']]]);
$names = collect($response->json())->pluck('tags.name');
expect($names->all())
->toContain('BTC Map Meetup')
->not->toContain('Excluded Meetup');
});
it('returns meetup events as JSON on GET /api/meetup-events', function () {
$meetup = Meetup::factory()->create(['city_id' => $this->city->id]);
MeetupEvent::factory()->create([
'meetup_id' => $meetup->id,
'start' => now()->addDay(),
]);
$response = $this->getJson('/api/meetup-events');
$response->assertSuccessful();
expect($response->json())->toBeArray()->not->toBeEmpty();
});
it('filters /api/meetup-events by date when one is supplied', function () {
$meetup = Meetup::factory()->create(['city_id' => $this->city->id]);
MeetupEvent::factory()->create(['meetup_id' => $meetup->id, 'start' => now()->addMonth()->startOfMonth()->addDays(5)]);
$date = now()->addMonth()->startOfMonth()->format('Y-m-d');
$response = $this->getJson("/api/meetup-events/{$date}");
$response->assertSuccessful();
expect($response->json())->toBeArray()->not->toBeEmpty();
});
@@ -0,0 +1,43 @@
<?php
use Illuminate\Auth\Events\Verified;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
it('verifies the email when the signed URL is correct', function () {
Event::fake();
$user = actingAsUser(['email_verified_at' => null]);
$verifyUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)],
);
$this->get($verifyUrl)->assertRedirect();
expect($user->refresh()->hasVerifiedEmail())->toBeTrue();
Event::assertDispatched(Verified::class);
});
it('does not re-fire the Verified event when email is already verified', function () {
Event::fake();
$user = actingAsUser(['email_verified_at' => now()]);
$verifyUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)],
);
$this->get($verifyUrl)->assertRedirect();
Event::assertNotDispatched(Verified::class);
});
it('rejects an invalid signed URL with 403', function () {
actingAsUser(['email_verified_at' => null]);
$this->get(route('verification.verify', ['id' => 1, 'hash' => 'invalid']))
->assertForbidden();
});
+60
View File
@@ -0,0 +1,60 @@
<?php
use App\Models\LoginKey;
use App\Models\User;
it('returns invalid request parameters when k1 is missing', function () {
$this->get('/api/lnurl-auth-callback')
->assertStatus(400)
->assertJson([
'status' => 'ERROR',
'reason' => 'Invalid request parameters',
]);
});
it('returns invalid request parameters when k1 is the wrong length', function () {
$this->getJson('/api/lnurl-auth-callback?'.http_build_query([
'k1' => 'tooshort',
'sig' => str_repeat('a', 128),
'key' => str_repeat('a', 64),
]))
->assertStatus(400)
->assertJson(['status' => 'ERROR']);
});
it('returns invalid request parameters when k1 is not hex', function () {
$this->getJson('/api/lnurl-auth-callback?'.http_build_query([
'k1' => str_repeat('Z', 64),
'sig' => str_repeat('a', 128),
'key' => str_repeat('a', 64),
]))
->assertStatus(400)
->assertJson(['status' => 'ERROR']);
});
it('returns no error from /api/check-auth-error when k1 is missing', function () {
$this->postJson('/api/check-auth-error', [])
->assertSuccessful()
->assertJson(['error' => null]);
});
it('returns no error from /api/check-auth-error when a recent LoginKey exists', function () {
$user = User::factory()->create();
$loginKey = LoginKey::factory()->create([
'user_id' => $user->id,
'created_at' => now(),
]);
$this->postJson('/api/check-auth-error', ['k1' => $loginKey->k1])
->assertSuccessful()
->assertJson(['error' => null]);
});
it('returns a session-expired error when no LoginKey exists and elapsed_seconds exceeds 300', function () {
$this->postJson('/api/check-auth-error', [
'k1' => str_repeat('a', 64),
'elapsed_seconds' => 400,
])
->assertSuccessful()
->assertJson(['error' => 'Session expired. Please try again.']);
});
+37
View File
@@ -0,0 +1,37 @@
<?php
use App\Jobs\FetchNostrProfileJob;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
it('creates a new user and dispatches FetchNostrProfileJob when an unknown pubkey logs in', function () {
Queue::fake();
$pubkey = 'npub1'.str_repeat('z', 58);
Livewire::test('auth.login')
->dispatch('nostrLoggedIn', pubkey: $pubkey)
->assertRedirect();
$user = User::query()->where('nostr', $pubkey)->first();
expect($user)->not->toBeNull()
->and((bool) $user->is_lecturer)->toBeTrue()
->and($user->email)->toEndWith('@portal.einundzwanzig.space');
Queue::assertPushed(FetchNostrProfileJob::class);
expect(auth()->id())->toBe($user->id);
});
it('logs in an existing user without creating a duplicate when their pubkey is already known', function () {
Queue::fake();
$pubkey = 'npub1'.str_repeat('a', 58);
$existing = User::factory()->create(['nostr' => $pubkey]);
Livewire::test('auth.login')
->dispatch('nostrLoggedIn', pubkey: $pubkey)
->assertRedirect();
expect(User::query()->where('nostr', $pubkey)->count())->toBe(1);
expect(auth()->id())->toBe($existing->id);
Queue::assertPushed(FetchNostrProfileJob::class);
});
+82
View File
@@ -0,0 +1,82 @@
<?php
use App\Models\City;
use App\Models\Country;
use Livewire\Livewire;
beforeEach(function () {
$this->country = Country::factory()->create(['code' => 'de']);
});
it('creates a City with valid data', function () {
actingAsUser();
Livewire::test('cities.create')
->set('name', 'Berlin')
->set('country_id', $this->country->id)
->set('latitude', 52.52)
->set('longitude', 13.405)
->call('createCity')
->assertHasNoErrors();
expect(City::query()->where('name', 'Berlin')->exists())->toBeTrue();
});
it('rejects city creation without a name (country_id is preset by mount() from the route prefix)', function () {
actingAsUser();
Livewire::test('cities.create')
->call('createCity')
->assertHasErrors(['name' => 'required']);
});
it('rejects city creation when country_id is explicitly cleared', function () {
actingAsUser();
Livewire::test('cities.create')
->set('name', 'No Country City')
->set('country_id', null)
->set('latitude', 1)
->set('longitude', 1)
->call('createCity')
->assertHasErrors(['country_id' => 'required']);
});
it('rejects city creation with out-of-range latitude', function () {
actingAsUser();
Livewire::test('cities.create')
->set('name', 'Bad Lat')
->set('country_id', $this->country->id)
->set('latitude', 150)
->set('longitude', 0)
->call('createCity')
->assertHasErrors(['latitude' => 'between']);
});
it('rejects city creation with non-existent country', function () {
actingAsUser();
Livewire::test('cities.create')
->set('name', 'No Country')
->set('country_id', 999999)
->set('latitude', 0)
->set('longitude', 0)
->call('createCity')
->assertHasErrors(['country_id' => 'exists']);
});
it('updates an existing city', function () {
$city = City::factory()->create(['name' => 'Old Name', 'country_id' => $this->country->id]);
actingAsUser();
Livewire::test('cities.edit', ['city' => $city])
->set('name', 'New Name')
->set('country_id', $this->country->id)
->set('latitude', 52.52)
->set('longitude', 13.405)
->call('updateCity')
->assertHasNoErrors();
expect($city->refresh()->name)->toBe('New Name');
});
@@ -0,0 +1,28 @@
<?php
use App\Models\LoginKey;
use App\Models\User;
it('deletes login keys older than 1 day and keeps recent ones', function () {
$user = User::factory()->create();
$old = LoginKey::factory()->create([
'user_id' => $user->id,
'created_at' => now()->subDays(2),
'updated_at' => now()->subDays(2),
]);
$recent = LoginKey::factory()->create([
'user_id' => $user->id,
'created_at' => now()->subHours(2),
'updated_at' => now()->subHours(2),
]);
$this->artisan('loginkeys:cleanup')->assertExitCode(0);
expect(LoginKey::query()->find($old->id))->toBeNull();
expect(LoginKey::query()->find($recent->id))->not->toBeNull();
});
it('runs cleanly when no login keys exist', function () {
$this->artisan('loginkeys:cleanup')->assertExitCode(0);
});
+66
View File
@@ -0,0 +1,66 @@
<?php
use App\Models\Course;
use App\Models\Lecturer;
use Livewire\Livewire;
beforeEach(function () {
$this->lecturer = Lecturer::factory()->create();
});
it('creates a Course with valid data', function () {
actingAsUser();
Livewire::test('courses.create')
->set('name', 'Bitcoin 101')
->set('lecturer_id', $this->lecturer->id)
->call('createCourse')
->assertHasNoErrors();
expect(Course::query()->where('name', 'Bitcoin 101')->exists())->toBeTrue();
});
it('rejects course creation without a name', function () {
actingAsUser();
Livewire::test('courses.create')
->set('lecturer_id', $this->lecturer->id)
->call('createCourse')
->assertHasErrors(['name' => 'required']);
});
it('rejects course creation without a lecturer', function () {
actingAsUser();
Livewire::test('courses.create')
->set('name', 'Course Without Lecturer')
->call('createCourse')
->assertHasErrors(['lecturer_id' => 'required']);
});
it('rejects course creation with non-existent lecturer_id', function () {
actingAsUser();
Livewire::test('courses.create')
->set('name', 'Bad Lecturer Course')
->set('lecturer_id', 999999)
->call('createCourse')
->assertHasErrors(['lecturer_id' => 'exists']);
});
it('updates an existing course when authenticated', function () {
$course = Course::factory()->create(['name' => 'Old Name', 'lecturer_id' => $this->lecturer->id]);
actingAsUser();
Livewire::test('courses.edit', ['course' => $course])
->set('name', 'New Name')
->set('lecturer_id', $this->lecturer->id)
->call('updateCourse')
->assertHasNoErrors();
expect($course->refresh()->name)->toBe('New Name');
});
it('redirects guests when accessing course-create', function () {
$this->get('/de/course-create')->assertRedirect(route('login'));
});
@@ -0,0 +1,36 @@
<?php
use App\Jobs\FetchNostrProfileJob;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
it('can be dispatched for a single user', function () {
Queue::fake();
$user = User::factory()->create(['nostr' => 'npub1'.str_repeat('a', 58)]);
FetchNostrProfileJob::dispatch($user);
Queue::assertPushed(
FetchNostrProfileJob::class,
fn (FetchNostrProfileJob $job) => $job->user?->id === $user->id,
);
});
it('can be dispatched without a user (batch mode)', function () {
Queue::fake();
FetchNostrProfileJob::dispatch();
Queue::assertPushed(
FetchNostrProfileJob::class,
fn (FetchNostrProfileJob $job) => $job->user === null,
);
});
it('returns early when the supplied user has no nostr handle', function () {
$user = User::factory()->create(['nostr' => null]);
(new FetchNostrProfileJob($user))->handle();
expect($user->refresh()->name)->not->toBeEmpty();
});
@@ -0,0 +1,59 @@
<?php
use App\Models\Lecturer;
use Livewire\Livewire;
it('creates a Lecturer with valid data', function () {
actingAsUser();
Livewire::test('lecturers.create')
->set('name', 'Satoshi Nakamoto')
->call('createLecturer')
->assertHasNoErrors();
expect(Lecturer::query()->where('name', 'Satoshi Nakamoto')->exists())->toBeTrue();
});
it('rejects lecturer creation without name', function () {
actingAsUser();
Livewire::test('lecturers.create')
->call('createLecturer')
->assertHasErrors(['name' => 'required']);
});
it('rejects lecturer creation with duplicate name', function () {
Lecturer::factory()->create(['name' => 'Already Exists']);
actingAsUser();
Livewire::test('lecturers.create')
->set('name', 'Already Exists')
->call('createLecturer')
->assertHasErrors(['name' => 'unique']);
});
it('rejects lecturer creation with invalid website URL', function () {
actingAsUser();
Livewire::test('lecturers.create')
->set('name', 'Bad URL Lecturer')
->set('website', 'not-a-url')
->call('createLecturer')
->assertHasErrors(['website' => 'url']);
});
it('updates an existing lecturer', function () {
$lecturer = Lecturer::factory()->create(['name' => 'Old Name']);
actingAsUser();
Livewire::test('lecturers.edit', ['lecturer' => $lecturer])
->set('name', 'New Name')
->call('updateLecturer')
->assertHasNoErrors();
expect($lecturer->refresh()->name)->toBe('New Name');
});
it('redirects guests when accessing lecturer-create', function () {
$this->get('/de/lecturer-create')->assertRedirect(route('login'));
});
@@ -0,0 +1,26 @@
<?php
use App\Livewire\Actions\Logout;
it('logs the authenticated user out and redirects to /', function () {
actingAsUser();
expect(auth()->check())->toBeTrue();
$response = (new Logout)();
expect($response->getTargetUrl())->toBe(url('/'));
expect(auth()->check())->toBeFalse();
});
it('still produces a redirect when invoked without an authenticated session', function () {
$response = (new Logout)();
expect($response->getTargetUrl())->toBe(url('/'));
});
it('is registered for the POST /logout route', function () {
actingAsUser();
$this->post('/logout')->assertRedirect('/');
});
+31
View File
@@ -0,0 +1,31 @@
<?php
use Livewire\Livewire;
it('mounts the auth.login component', function () {
Livewire::test('auth.login')->assertStatus(200);
});
it('mounts the auth.register component', function () {
Livewire::test('auth.register')->assertStatus(200);
});
it('mounts the auth.forgot-password component', function () {
Livewire::test('auth.forgot-password')->assertStatus(200);
});
it('mounts the auth.reset-password component with a token', function () {
Livewire::withQueryParams(['email' => 'foo@example.com'])
->test('auth.reset-password', ['token' => 'fake-reset-token'])
->assertStatus(200);
});
it('mounts the auth.confirm-password component', function () {
actingAsUser();
Livewire::test('auth.confirm-password')->assertStatus(200);
});
it('mounts the auth.verify-email component', function () {
actingAsUser();
Livewire::test('auth.verify-email')->assertStatus(200);
});
@@ -0,0 +1,14 @@
<?php
use App\Livewire\BooksForPlebs\BookRentalGuide;
use Illuminate\View\ViewException;
use Livewire\Livewire;
it('mounts the BookRentalGuide component but its view references a route that is currently commented out in routes/web.php', function () {
expect(fn () => Livewire::test(BookRentalGuide::class)->assertStatus(200))
->toThrow(ViewException::class, 'Route [buecherverleih.download] not defined.');
})->skip('Component is unreachable: /buecherverleih route is commented out in routes/web.php — view references the missing buecherverleih.download route.');
it('confirms the BookRentalGuide component class still exists', function () {
expect(class_exists(BookRentalGuide::class))->toBeTrue();
});
@@ -0,0 +1,55 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Course;
use App\Models\CourseEvent;
use App\Models\Lecturer;
use App\Models\Venue;
use Livewire\Livewire;
beforeEach(function () {
$country = Country::factory()->create(['code' => 'de']);
$city = City::factory()->create(['country_id' => $country->id]);
$venue = Venue::factory()->create(['city_id' => $city->id]);
$this->course = Course::factory()->create();
$this->lecturer = Lecturer::factory()->create();
$this->event = CourseEvent::factory()->create([
'course_id' => $this->course->id,
'venue_id' => $venue->id,
]);
});
it('mounts courses.landingpage with a course', function () {
Livewire::test('courses.landingpage', ['course' => $this->course])->assertStatus(200);
});
it('skips courses.landingpage-event because the Volt component file does not exist (route is broken)', function () {
$path = resource_path('views/livewire/courses/landingpage-event.blade.php');
expect(file_exists($path))->toBeFalse(
'The route /course/{course}/event/{event} maps to a missing component file at '.$path
);
});
it('mounts courses.create when authenticated', function () {
actingAsUser();
Livewire::test('courses.create')->assertStatus(200);
});
it('mounts courses.edit when authenticated', function () {
actingAsUser();
Livewire::test('courses.edit', ['course' => $this->course])->assertStatus(200);
});
it('mounts courses.create-edit-events for new event', function () {
actingAsUser();
Livewire::test('courses.create-edit-events', ['course' => $this->course])->assertStatus(200);
});
it('mounts courses.create-edit-events for existing event', function () {
actingAsUser();
Livewire::test('courses.create-edit-events', [
'course' => $this->course,
'event' => $this->event,
])->assertStatus(200);
});
+68
View File
@@ -0,0 +1,68 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Lecturer;
use App\Models\SelfHostedService;
use App\Models\Venue;
use Livewire\Livewire;
beforeEach(function () {
$country = Country::factory()->create(['code' => 'de']);
$this->city = City::factory()->create(['country_id' => $country->id]);
$this->venue = Venue::factory()->create(['city_id' => $this->city->id]);
$this->lecturer = Lecturer::factory()->create();
$this->service = SelfHostedService::factory()->create();
});
it('mounts lecturers.create when authenticated', function () {
actingAsUser();
Livewire::test('lecturers.create')->assertStatus(200);
});
it('mounts lecturers.edit when authenticated', function () {
actingAsUser();
Livewire::test('lecturers.edit', ['lecturer' => $this->lecturer])->assertStatus(200);
});
it('mounts cities.create when authenticated', function () {
actingAsUser();
Livewire::test('cities.create')->assertStatus(200);
});
it('mounts cities.edit when authenticated', function () {
actingAsUser();
Livewire::test('cities.edit', ['city' => $this->city])->assertStatus(200);
});
it('mounts venues.create when authenticated', function () {
actingAsUser();
Livewire::test('venues.create')->assertStatus(200);
});
it('mounts venues.edit when authenticated', function () {
actingAsUser();
Livewire::test('venues.edit', ['venue' => $this->venue])->assertStatus(200);
});
it('mounts services.create when authenticated', function () {
actingAsUser();
Livewire::test('services.create')->assertStatus(200);
});
it('mounts services.edit when authenticated as the service creator', function () {
$owner = actingAsUser();
$service = SelfHostedService::factory()->create(['created_by' => $owner->id]);
Livewire::test('services.edit', ['service' => $service])->assertStatus(200);
});
it('aborts services.edit with 403 when authenticated user is not the creator', function () {
actingAsUser();
Livewire::test('services.edit', ['service' => $this->service])->assertStatus(403);
});
it('mounts services.landingpage with a service', function () {
Livewire::test('services.landingpage', ['service' => $this->service])->assertStatus(200);
});
@@ -0,0 +1,149 @@
<?php
use App\Enums\SelfHostedServiceType;
use App\Models\SelfHostedService;
use Livewire\Livewire;
it('mounts services.create when authenticated', function () {
actingAsUser();
Livewire::test('services.create')
->assertStatus(200)
->assertSet('form.name', '')
->assertSet('form.anonymous', false);
});
it('persists a service when valid data is submitted', function () {
$user = actingAsUser();
Livewire::test('services.create')
->set('form.name', 'Mein Mempool')
->set('form.type', SelfHostedServiceType::Mempool->value)
->set('form.url_clearnet', 'https://mempool.example.com')
->call('save')
->assertHasNoErrors();
$service = SelfHostedService::query()->where('name', 'Mein Mempool')->first();
expect($service)->not->toBeNull()
->and($service->type)->toBe(SelfHostedServiceType::Mempool)
->and($service->created_by)->toBe($user->id);
});
it('rejects submission when no URL or IP is provided', function () {
actingAsUser();
Livewire::test('services.create')
->set('form.name', 'No Endpoint')
->set('form.type', SelfHostedServiceType::Other->value)
->call('save');
expect(SelfHostedService::query()->where('name', 'No Endpoint')->exists())->toBeFalse();
});
it('validates required name and type fields', function () {
actingAsUser();
Livewire::test('services.create')
->call('save')
->assertHasErrors([
'form.name' => 'required',
'form.type' => 'required',
]);
});
it('rejects invalid clearnet URLs', function () {
actingAsUser();
Livewire::test('services.create')
->set('form.name', 'Bad URL')
->set('form.type', SelfHostedServiceType::Other->value)
->set('form.url_clearnet', 'not-a-valid-url')
->call('save')
->assertHasErrors(['form.url_clearnet' => 'url']);
});
it('rejects invalid IP addresses', function () {
actingAsUser();
Livewire::test('services.create')
->set('form.name', 'Bad IP')
->set('form.type', SelfHostedServiceType::Other->value)
->set('form.ip', 'not-an-ip')
->call('save')
->assertHasErrors(['form.ip' => 'ip']);
});
it('rejects unknown service types — currently the value reaches the enum cast and triggers a ValueError (rules() in:... is not enforced)', function () {
actingAsUser();
expect(function () {
Livewire::test('services.create')
->set('form.name', 'Bogus Type')
->set('form.type', 'NotARealType')
->set('form.url_clearnet', 'https://example.com')
->call('save');
})->toThrow(ValueError::class);
expect(SelfHostedService::query()->where('name', 'Bogus Type')->exists())->toBeFalse();
});
it('accepts every SelfHostedServiceType enum value as form.type', function (SelfHostedServiceType $type) {
actingAsUser();
Livewire::test('services.create')
->set('form.name', 'Service '.$type->value)
->set('form.type', $type->value)
->set('form.url_clearnet', 'https://example.com')
->call('save')
->assertHasNoErrors();
})->with(SelfHostedServiceType::cases());
it('updates an existing service when authenticated as the creator', function () {
$user = actingAsUser();
$service = SelfHostedService::factory()->create([
'created_by' => $user->id,
'name' => 'Old Name',
]);
Livewire::test('services.edit', ['service' => $service])
->set('form.name', 'New Name')
->set('form.type', SelfHostedServiceType::Mempool->value)
->set('form.url_clearnet', 'https://mempool.example.com')
->call('save')
->assertHasNoErrors();
expect($service->refresh()->name)->toBe('New Name');
});
it('marks anonymous service correctly via the anonymous flag', function () {
actingAsUser();
Livewire::test('services.create')
->set('form.name', 'Anon Service')
->set('form.type', SelfHostedServiceType::Other->value)
->set('form.url_clearnet', 'https://example.com')
->set('form.anonymous', true)
->call('save')
->assertHasNoErrors();
$service = SelfHostedService::query()->where('name', 'Anon Service')->first();
expect($service->anon)->toBeTrue();
});
it('initializes the form properly via setService when editing', function () {
$user = actingAsUser();
$service = SelfHostedService::factory()->create([
'created_by' => $user->id,
'name' => 'Filled Service',
'type' => SelfHostedServiceType::Alby,
'url_clearnet' => 'https://alby.example.com',
'anon' => true,
]);
Livewire::test('services.edit', ['service' => $service])
->assertSet('form.name', 'Filled Service')
->assertSet('form.type', SelfHostedServiceType::Alby->value)
->assertSet('form.url_clearnet', 'https://alby.example.com')
->assertSet('form.anonymous', true);
});
@@ -0,0 +1,12 @@
<?php
use App\Livewire\Helper\FollowTheRabbit;
use Livewire\Livewire;
it('mounts the FollowTheRabbit component', function () {
Livewire::test(FollowTheRabbit::class)->assertStatus(200);
});
it('is referenced by the /kaninchenbau route', function () {
$this->get('/kaninchenbau')->assertSuccessful();
});
@@ -0,0 +1,48 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Meetup;
use App\Models\MeetupEvent;
use Livewire\Livewire;
beforeEach(function () {
$this->country = Country::factory()->create(['code' => 'de']);
$this->city = City::factory()->create(['country_id' => $this->country->id]);
$this->meetup = Meetup::factory()->create(['city_id' => $this->city->id]);
$this->event = MeetupEvent::factory()->create(['meetup_id' => $this->meetup->id]);
});
it('mounts meetups.landingpage with a meetup', function () {
Livewire::test('meetups.landingpage', ['meetup' => $this->meetup])->assertStatus(200);
});
it('mounts meetups.landingpage-event with meetup and event', function () {
Livewire::test('meetups.landingpage-event', [
'meetup' => $this->meetup,
'event' => $this->event,
])->assertStatus(200);
});
it('mounts meetups.create when authenticated', function () {
actingAsUser();
Livewire::test('meetups.create')->assertStatus(200);
});
it('mounts meetups.edit when authenticated', function () {
actingAsUser();
Livewire::test('meetups.edit', ['meetup' => $this->meetup])->assertStatus(200);
});
it('mounts meetups.create-edit-events for new event', function () {
actingAsUser();
Livewire::test('meetups.create-edit-events', ['meetup' => $this->meetup])->assertStatus(200);
});
it('mounts meetups.create-edit-events for existing event', function () {
actingAsUser();
Livewire::test('meetups.create-edit-events', [
'meetup' => $this->meetup,
'event' => $this->event,
])->assertStatus(200);
});
@@ -0,0 +1,48 @@
<?php
use Livewire\Livewire;
it('mounts settings.profile when authenticated', function () {
actingAsUser();
Livewire::test('settings.profile')->assertStatus(200);
});
it('mounts settings.password when authenticated', function () {
actingAsUser();
Livewire::test('settings.password')->assertStatus(200);
});
it('mounts settings.appearance when authenticated', function () {
actingAsUser();
Livewire::test('settings.appearance')->assertStatus(200);
});
it('mounts settings.delete-user-form when authenticated', function () {
actingAsUser();
Livewire::test('settings.delete-user-form')->assertStatus(200);
});
it('mounts welcome', function () {
Livewire::test('welcome')->assertStatus(200);
});
it('mounts language.selector', function () {
Livewire::test('language.selector')->assertStatus(200);
});
it('mounts timezone.chooser', function () {
Livewire::test('timezone.chooser')->assertStatus(200);
});
it('mounts dashboard.activities', function () {
actingAsUser();
Livewire::test('dashboard.activities')->assertStatus(200);
});
it('mounts dashboard.top-countries', function () {
Livewire::test('dashboard.top-countries')->assertStatus(200);
});
it('mounts dashboard.top-meetups', function () {
Livewire::test('dashboard.top-meetups')->assertStatus(200);
});
+100
View File
@@ -0,0 +1,100 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Meetup;
use Livewire\Livewire;
beforeEach(function () {
$country = Country::factory()->create(['code' => 'de']);
$this->city = City::factory()->create(['country_id' => $country->id]);
});
it('creates a Meetup when authenticated user submits a valid form', function () {
actingAsUser();
Livewire::test('meetups.create')
->set('name', 'Berlin Bitcoin Meetup')
->set('city_id', $this->city->id)
->set('community', 'einundzwanzig')
->call('createMeetup')
->assertHasNoErrors()
->assertRedirect();
$meetup = Meetup::query()->where('name', 'Berlin Bitcoin Meetup')->first();
expect($meetup)->not->toBeNull()
->and($meetup->city_id)->toBe($this->city->id);
});
it('rejects creation without a name', function () {
actingAsUser();
Livewire::test('meetups.create')
->set('city_id', $this->city->id)
->set('community', 'einundzwanzig')
->call('createMeetup')
->assertHasErrors(['name' => 'required']);
});
it('rejects creation without city_id', function () {
actingAsUser();
Livewire::test('meetups.create')
->set('name', 'No City Meetup')
->set('community', 'einundzwanzig')
->call('createMeetup')
->assertHasErrors(['city_id' => 'required']);
});
it('rejects creation with non-existent city_id', function () {
actingAsUser();
Livewire::test('meetups.create')
->set('name', 'Bad City Meetup')
->set('city_id', 999999)
->set('community', 'einundzwanzig')
->call('createMeetup')
->assertHasErrors(['city_id' => 'exists']);
});
it('rejects creation with a duplicate meetup name', function () {
Meetup::factory()->create(['name' => 'Already Exists', 'city_id' => $this->city->id]);
actingAsUser();
Livewire::test('meetups.create')
->set('name', 'Already Exists')
->set('city_id', $this->city->id)
->set('community', 'einundzwanzig')
->call('createMeetup')
->assertHasErrors(['name' => 'unique']);
});
it('rejects creation when telegram_link is not a valid URL', function () {
actingAsUser();
Livewire::test('meetups.create')
->set('name', 'Bad URL Meetup')
->set('city_id', $this->city->id)
->set('community', 'einundzwanzig')
->set('telegram_link', 'not-a-url')
->call('createMeetup')
->assertHasErrors(['telegram_link' => 'url']);
});
it('redirects guests to login when accessing meetup-create', function () {
$this->get('/de/meetup-create')->assertRedirect(route('login'));
});
it('creates a city via createCity within the meetup-create flow', function () {
actingAsUser();
Livewire::test('meetups.create')
->set('newCityName', 'Hamburg')
->set('newCityCountryId', $this->city->country_id)
->set('newCityLatitude', 53.5511)
->set('newCityLongitude', 9.9937)
->call('createCity')
->assertHasNoErrors();
expect(City::query()->where('name', 'Hamburg')->exists())->toBeTrue();
});
+49
View File
@@ -0,0 +1,49 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Meetup;
use Livewire\Livewire;
beforeEach(function () {
$country = Country::factory()->create(['code' => 'de']);
$this->city = City::factory()->create(['country_id' => $country->id]);
$this->meetup = Meetup::factory()->create(['city_id' => $this->city->id, 'name' => 'Original Name']);
});
it('updates an existing Meetup name when authenticated', function () {
actingAsUser();
Livewire::test('meetups.edit', ['meetup' => $this->meetup])
->set('name', 'Updated Name')
->set('city_id', $this->city->id)
->set('community', 'einundzwanzig')
->call('updateMeetup')
->assertHasNoErrors();
expect($this->meetup->refresh()->name)->toBe('Updated Name');
});
it('rejects update when name collides with another existing Meetup', function () {
Meetup::factory()->create(['name' => 'Other Name', 'city_id' => $this->city->id]);
actingAsUser();
Livewire::test('meetups.edit', ['meetup' => $this->meetup])
->set('name', 'Other Name')
->call('updateMeetup')
->assertHasErrors(['name' => 'unique']);
});
it('allows update when name is unchanged (Rule::unique ignores own id)', function () {
actingAsUser();
Livewire::test('meetups.edit', ['meetup' => $this->meetup])
->set('name', 'Original Name')
->set('community', 'einundzwanzig')
->call('updateMeetup')
->assertHasNoErrors();
});
it('redirects guests when accessing meetup-edit', function () {
$this->get('/de/meetup-edit/'.$this->meetup->id)->assertRedirect(route('login'));
});
+72
View File
@@ -0,0 +1,72 @@
<?php
use App\Models\BitcoinEvent;
use App\Models\Category;
use App\Models\City;
use App\Models\Country;
use App\Models\Course;
use App\Models\CourseEvent;
use App\Models\EmailCampaign;
use App\Models\EmailTexts;
use App\Models\Episode;
use App\Models\Highscore;
use App\Models\Lecturer;
use App\Models\Library;
use App\Models\LibraryItem;
use App\Models\LoginKey;
use App\Models\Meetup;
use App\Models\MeetupEvent;
use App\Models\Participant;
use App\Models\Podcast;
use App\Models\ProjectProposal;
use App\Models\Registration;
use App\Models\SelfHostedService;
use App\Models\Tag;
use App\Models\TwitterAccount;
use App\Models\User;
use App\Models\Venue;
use App\Models\Vote;
use Illuminate\Database\Eloquent\Model;
it('creates a valid persisted record via the factory', function (string $modelClass): void {
/** @var Model $model */
$model = $modelClass::factory()->create();
expect($model)
->toBeInstanceOf($modelClass)
->and($model->getKey())->not->toBeNull()
->and($model->exists)->toBeTrue();
expect($modelClass::query()->whereKey($model->getKey())->exists())->toBeTrue();
})->with([
'User' => User::class,
'Country' => Country::class,
'City' => City::class,
'Lecturer' => Lecturer::class,
'Venue' => Venue::class,
'Category' => Category::class,
'Course' => Course::class,
'CourseEvent' => CourseEvent::class,
'Meetup' => Meetup::class,
'MeetupEvent' => MeetupEvent::class,
'BitcoinEvent' => BitcoinEvent::class,
'Library' => Library::class,
'LibraryItem' => LibraryItem::class,
'Episode' => Episode::class,
'Podcast' => Podcast::class,
'ProjectProposal' => ProjectProposal::class,
'Vote' => Vote::class,
'TwitterAccount' => TwitterAccount::class,
'SelfHostedService' => SelfHostedService::class,
'Registration' => Registration::class,
'Participant' => Participant::class,
'EmailCampaign' => EmailCampaign::class,
'EmailTexts' => EmailTexts::class,
'Highscore' => Highscore::class,
'LoginKey' => LoginKey::class,
'Tag' => Tag::class,
]);
it('skips the App\\Models\\Team factory since Laravel Jetstream is not installed', function (): void {
expect(class_exists('Laravel\\Jetstream\\Team'))->toBeFalse();
})->skip(class_exists('Laravel\\Jetstream\\Team'), 'Jetstream installed — Team factory should be tested in the main dataset.');
@@ -0,0 +1,26 @@
<?php
use App\Models\Meetup;
use App\Models\User;
use App\Notifications\ModelCreatedNotification;
use Illuminate\Support\Facades\Notification;
it('sends a queued mail notification for a created model', function () {
Notification::fake();
$user = User::factory()->create();
$meetup = Meetup::factory()->create();
$user->notify(new ModelCreatedNotification($meetup, 'meetups'));
Notification::assertSentTo($user, ModelCreatedNotification::class, function ($notification) use ($meetup) {
return $notification->model->is($meetup) && $notification->resource === 'meetups';
});
});
it('uses the mail channel', function () {
$user = User::factory()->create();
$notification = new ModelCreatedNotification(User::factory()->make(), 'users');
expect($notification->via($user))->toBe(['mail']);
});
+29
View File
@@ -0,0 +1,29 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Livewire\Livewire;
it('deletes the user and logs them out when password is correct', function () {
$user = actingAsUser(['password' => Hash::make('correct-password')]);
Livewire::test('settings.delete-user-form')
->set('password', 'correct-password')
->call('deleteUser')
->assertHasNoErrors()
->assertRedirect('/');
expect(User::query()->find($user->id))->toBeNull();
expect(auth()->check())->toBeFalse();
});
it('does not delete the user with an incorrect password', function () {
$user = actingAsUser(['password' => Hash::make('correct-password')]);
Livewire::test('settings.delete-user-form')
->set('password', 'wrong-password')
->call('deleteUser')
->assertHasErrors(['password' => 'current_password']);
expect(User::query()->find($user->id))->not->toBeNull();
});
+40
View File
@@ -0,0 +1,40 @@
<?php
use Illuminate\Support\Facades\Hash;
use Livewire\Livewire;
it('updates the password when current password is correct', function () {
$user = actingAsUser(['password' => Hash::make('old-password')]);
Livewire::test('settings.password')
->set('current_password', 'old-password')
->set('password', 'new-strong-password!')
->set('password_confirmation', 'new-strong-password!')
->call('updatePassword')
->assertHasNoErrors()
->assertDispatched('password-updated');
expect(Hash::check('new-strong-password!', $user->refresh()->password))->toBeTrue();
});
it('rejects an incorrect current password', function () {
actingAsUser(['password' => Hash::make('correct-password')]);
Livewire::test('settings.password')
->set('current_password', 'wrong-password')
->set('password', 'new-strong-password!')
->set('password_confirmation', 'new-strong-password!')
->call('updatePassword')
->assertHasErrors(['current_password' => 'current_password']);
});
it('rejects mismatched password confirmation', function () {
actingAsUser(['password' => Hash::make('correct-password')]);
Livewire::test('settings.password')
->set('current_password', 'correct-password')
->set('password', 'new-strong-password!')
->set('password_confirmation', 'different-confirmation')
->call('updatePassword')
->assertHasErrors(['password' => 'confirmed']);
});
+54
View File
@@ -0,0 +1,54 @@
<?php
use App\Models\User;
use Livewire\Livewire;
it('updates the profile name and email when authenticated', function () {
$user = actingAsUser(['email' => 'old@example.com', 'name' => 'Old Name']);
Livewire::test('settings.profile')
->set('name', 'New Name')
->set('email', 'new@example.com')
->call('updateProfileInformation')
->assertHasNoErrors()
->assertDispatched('profile-updated', name: 'New Name');
expect($user->refresh())
->name->toBe('New Name')
->email->toBe('new@example.com')
->email_verified_at->toBeNull();
});
it('rejects an empty name', function () {
actingAsUser();
Livewire::test('settings.profile')
->set('name', '')
->call('updateProfileInformation')
->assertHasErrors(['name' => 'required']);
});
it('does NOT enforce the unique-email rule because the email column is CipherSweet-encrypted (Rule::unique scans plain values against the encrypted column and never matches)', function () {
User::factory()->create(['email' => 'taken@example.com']);
actingAsUser();
Livewire::test('settings.profile')
->set('email', 'taken@example.com')
->call('updateProfileInformation')
->assertHasNoErrors();
});
it('keeps email_verified_at when email is unchanged', function () {
$user = actingAsUser([
'email' => 'same@example.com',
'email_verified_at' => now(),
]);
Livewire::test('settings.profile')
->set('name', 'Different Name')
->set('email', 'same@example.com')
->call('updateProfileInformation')
->assertHasNoErrors();
expect($user->refresh()->email_verified_at)->not->toBeNull();
});
+56
View File
@@ -0,0 +1,56 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Course;
use App\Models\CourseEvent;
use App\Models\Highscore;
use App\Models\Lecturer;
use App\Models\LibraryItem;
use App\Models\Meetup;
use App\Models\MeetupEvent;
use App\Models\User;
use App\Models\Venue;
beforeEach(function () {
$country = Country::factory()->create(['code' => 'de']);
$city = City::factory()->create(['country_id' => $country->id]);
Venue::factory()->create(['city_id' => $city->id]);
Meetup::factory()->create(['city_id' => $city->id, 'community' => 'einundzwanzig', 'visible_on_map' => true]);
MeetupEvent::factory()->create();
Course::factory()->create();
CourseEvent::factory()->create();
Lecturer::factory()->create();
Highscore::factory()->create();
LibraryItem::factory()->create(['type' => 'bindle']);
User::factory()->create(['nostr' => 'npub1'.str_repeat('a', 58)]);
});
it('returns a JSON response for the API GET endpoint', function (string $path) {
$this->getJson($path)->assertSuccessful();
})->with([
'countries' => '/api/countries',
'meetups' => '/api/meetups',
'meetup events' => '/api/meetup-events',
'btc-map communities' => '/api/btc-map-communities',
'nostrplebs' => '/api/nostrplebs',
'bindles' => '/api/bindles',
'lecturers' => '/api/lecturers',
'courses' => '/api/courses',
'cities' => '/api/cities',
'venues' => '/api/venues',
'highscores' => '/api/highscores',
]);
it('returns 404 for /api/meetup/ical (currently a stub that aborts)', function () {
$this->get('/api/meetup/ical')->assertNotFound();
});
it('returns 404 for /api/meetup index without user_id (currently aborts on missing param)', function () {
$this->getJson('/api/meetup')->assertNotFound();
});
it('returns a successful response for /stream-calendar', function () {
$response = $this->get('/stream-calendar');
$response->assertSuccessful();
});
+48
View File
@@ -0,0 +1,48 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Lecturer;
use App\Models\Meetup;
use App\Models\SelfHostedService;
use App\Models\Venue;
beforeEach(function () {
$country = Country::factory()->create(['code' => 'de']);
$city = City::factory()->create(['country_id' => $country->id]);
Venue::factory()->create(['city_id' => $city->id]);
Meetup::factory()->create(['city_id' => $city->id]);
Lecturer::factory()->create();
SelfHostedService::factory()->create();
});
it('returns successful response for authenticated routes', function (string $path) {
actingAsUser();
$this->get($path)->assertSuccessful();
})->with([
'meetup create' => '/de/meetup-create',
'course create' => '/de/course-create',
'lecturer create' => '/de/lecturer-create',
'city create' => '/de/city-create',
'venue create' => '/de/venue-create',
'service create' => '/de/service-create',
'settings profile' => '/de/settings/profile',
'settings password' => '/de/settings/password',
'settings appearance' => '/de/settings/appearance',
'verify email notice' => '/verify-email',
'confirm password' => '/confirm-password',
'dashboard' => '/de/dashboard',
]);
it('redirects to login when guest accesses protected routes', function (string $path) {
$this->get($path)->assertRedirect(route('login'));
})->with([
'meetup create' => '/de/meetup-create',
'service create' => '/de/service-create',
'settings profile' => '/de/settings/profile',
]);
it('redirects /de/settings to /settings/profile (current behaviour drops the country prefix)', function () {
actingAsUser();
$this->get('/de/settings')->assertRedirect('/settings/profile');
});
+60
View File
@@ -0,0 +1,60 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Course;
use App\Models\Lecturer;
use App\Models\Meetup;
use App\Models\MeetupEvent;
use App\Models\SelfHostedService;
use App\Models\Venue;
beforeEach(function () {
$country = Country::factory()->create(['code' => 'de']);
$city = City::factory()->create(['country_id' => $country->id]);
Venue::factory()->create(['city_id' => $city->id]);
$meetup = Meetup::factory()->create(['city_id' => $city->id]);
MeetupEvent::factory()->create(['meetup_id' => $meetup->id]);
Course::factory()->create();
Lecturer::factory()->create();
SelfHostedService::factory()->create();
});
it('returns a successful response for the listed public route', function (string $path) {
$this->get($path)->assertSuccessful();
})->with([
'welcome' => '/welcome',
'login' => '/login',
'register' => '/register',
'forgot password' => '/forgot-password',
'meetups index' => '/de/meetups',
'meetups all' => '/de/all-meetups',
'map' => '/de/map',
'map world' => '/de/map-world',
'courses index' => '/de/courses',
'lecturers index' => '/de/lecturers',
'cities index' => '/de/cities',
'venues index' => '/de/venues',
'services index' => '/de/services',
]);
it('redirects / to /welcome', function () {
$this->get('/')->assertRedirect('/welcome');
});
it('redirects /de/dashboard to login when guest', function () {
$this->get('/de/dashboard')->assertRedirect(route('login'));
});
it('renders /kaninchenbau as a Livewire helper page', function () {
$response = $this->get('/kaninchenbau');
expect($response->status())->toBeIn([200, 302]);
});
it('returns 404 for the application fallback route', function () {
$this->get('/this-route-does-not-exist')->assertNotFound();
});
it('aborts with the requested status code for /error/{code}', function () {
$this->get('/error/418')->assertStatus(418);
});
+62
View File
@@ -0,0 +1,62 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Venue;
use Livewire\Livewire;
beforeEach(function () {
$country = Country::factory()->create(['code' => 'de']);
$this->city = City::factory()->create(['country_id' => $country->id]);
});
it('creates a Venue with valid data', function () {
actingAsUser();
Livewire::test('venues.create')
->set('name', 'Bitcoin Hub Berlin')
->set('city_id', $this->city->id)
->set('street', 'Lichtenberger Str. 1')
->call('createVenue')
->assertHasNoErrors();
expect(Venue::query()->where('name', 'Bitcoin Hub Berlin')->exists())->toBeTrue();
});
it('rejects venue creation without required fields', function () {
actingAsUser();
Livewire::test('venues.create')
->call('createVenue')
->assertHasErrors([
'name' => 'required',
'city_id' => 'required',
'street' => 'required',
]);
});
it('rejects venue creation with duplicate name', function () {
Venue::factory()->create(['name' => 'Existing Venue', 'city_id' => $this->city->id]);
actingAsUser();
Livewire::test('venues.create')
->set('name', 'Existing Venue')
->set('city_id', $this->city->id)
->set('street', 'Some Street')
->call('createVenue')
->assertHasErrors(['name' => 'unique']);
});
it('updates an existing venue', function () {
$venue = Venue::factory()->create(['name' => 'Old Name', 'city_id' => $this->city->id]);
actingAsUser();
Livewire::test('venues.edit', ['venue' => $venue])
->set('name', 'New Name')
->set('city_id', $this->city->id)
->set('street', 'New Street 1')
->call('updateVenue')
->assertHasNoErrors();
expect($venue->refresh()->name)->toBe('New Name');
});