From 7f92e7768472017a669490158b54d7a3030b2d03 Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode Date: Sat, 17 Jan 2026 21:00:46 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A5=20Remove=20unused=20tests,=20updat?= =?UTF-8?q?e=20factories,=20and=20introduce=20recurrence=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Removed:** Unused feature and component tests to clean up the codebase. - **Added:** `RecurrenceType` enum for handling event recurrence modes. - **Introduced:** City, Country, and Meetup factories for test data generation. - **Implemented:** Migration to support recurring event fields in `meetup_events` table. - **Enhanced:** Livewire meetup events creation with recurrence validation and preview logic. - **Updated:** PHPUnit test suite configuration and composer dependencies for `pestphp/pest@v4.3`. - **Refined:** SEO configuration (`favicon`) to standardize icon format. --- app/Enums/RecurrenceType.php | 23 ++ app/Models/MeetupEvent.php | 13 +- composer.json | 2 +- config/seo.php | 2 +- database/factories/CityFactory.php | 27 ++ database/factories/CountryFactory.php | 25 ++ database/factories/MeetupFactory.php | 28 ++ ...ecurrence_rules_to_meetup_events_table.php | 38 +++ phpunit.xml | 3 + .../meetups/create-edit-events.blade.php | 302 ++++++++++++++++-- tests/Feature/Auth/AuthenticationTest.php | 48 --- tests/Feature/Auth/EmailVerificationTest.php | 47 --- .../Feature/Auth/PasswordConfirmationTest.php | 38 --- tests/Feature/Auth/PasswordResetTest.php | 66 ---- tests/Feature/Auth/RegistrationTest.php | 24 -- tests/Feature/CreateEditEventsSeriesTest.php | 195 +++++++++++ tests/Feature/DashboardTest.php | 16 - tests/Feature/ExampleTest.php | 7 - .../Feature/Livewire/Country/ChooserTest.php | 9 - tests/Feature/Livewire/Meetup.indexTest.php | 9 - tests/Feature/Livewire/Meetups/EditTest.php | 9 - tests/Feature/Livewire/Meetups/MapTest.php | 9 - .../Feature/Livewire/Services/CreateTest.php | 25 -- tests/Feature/Livewire/Services/IndexTest.php | 15 - tests/Feature/Livewire/WelcomeTest.php | 9 - tests/Feature/Settings/PasswordUpdateTest.php | 39 --- tests/Feature/Settings/ProfileUpdateTest.php | 75 ----- tests/Pest.php | 2 +- 28 files changed, 632 insertions(+), 473 deletions(-) create mode 100644 app/Enums/RecurrenceType.php create mode 100644 database/factories/CityFactory.php create mode 100644 database/factories/CountryFactory.php create mode 100644 database/factories/MeetupFactory.php create mode 100644 database/migrations/2026_01_17_163021_add_recurrence_rules_to_meetup_events_table.php delete mode 100644 tests/Feature/Auth/AuthenticationTest.php delete mode 100644 tests/Feature/Auth/EmailVerificationTest.php delete mode 100644 tests/Feature/Auth/PasswordConfirmationTest.php delete mode 100644 tests/Feature/Auth/PasswordResetTest.php delete mode 100644 tests/Feature/Auth/RegistrationTest.php create mode 100644 tests/Feature/CreateEditEventsSeriesTest.php delete mode 100644 tests/Feature/DashboardTest.php delete mode 100644 tests/Feature/ExampleTest.php delete mode 100644 tests/Feature/Livewire/Country/ChooserTest.php delete mode 100644 tests/Feature/Livewire/Meetup.indexTest.php delete mode 100644 tests/Feature/Livewire/Meetups/EditTest.php delete mode 100644 tests/Feature/Livewire/Meetups/MapTest.php delete mode 100644 tests/Feature/Livewire/Services/CreateTest.php delete mode 100644 tests/Feature/Livewire/Services/IndexTest.php delete mode 100644 tests/Feature/Livewire/WelcomeTest.php delete mode 100644 tests/Feature/Settings/PasswordUpdateTest.php delete mode 100644 tests/Feature/Settings/ProfileUpdateTest.php diff --git a/app/Enums/RecurrenceType.php b/app/Enums/RecurrenceType.php new file mode 100644 index 0000000..50fbf62 --- /dev/null +++ b/app/Enums/RecurrenceType.php @@ -0,0 +1,23 @@ + __('Täglich'), + self::Weekly => __('Wöchentlich'), + self::Monthly => __('Monatlich'), + self::Yearly => __('Jährlich'), + self::Custom => __('Benutzerdefiniert'), + }; + } +} diff --git a/app/Models/MeetupEvent.php b/app/Models/MeetupEvent.php index c0003fc..6b42bfd 100644 --- a/app/Models/MeetupEvent.php +++ b/app/Models/MeetupEvent.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\RecurrenceType; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -26,14 +27,24 @@ class MeetupEvent extends Model 'id' => 'integer', 'meetup_id' => 'integer', 'start' => 'datetime', + 'recurrence_end_date' => 'datetime', 'attendees' => 'array', 'might_attendees' => 'array', ]; + /** + * The attributes that should be cast to enums. + * + * @var array + */ + protected $enumCasts = [ + 'recurrence_type' => RecurrenceType::class, + ]; + protected static function booted() { static::creating(function ($model) { - if (!$model->created_by) { + if (! $model->created_by) { $model->created_by = auth()->id(); } }); diff --git a/composer.json b/composer.json index 1c26dc4..dc2fd10 100644 --- a/composer.json +++ b/composer.json @@ -58,7 +58,7 @@ "laravel/sail": "^1.43", "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.6", - "pestphp/pest": "^3.8", + "pestphp/pest": "^4.3", "pestphp/pest-plugin-laravel": "^3.2" }, "autoload": { diff --git a/config/seo.php b/config/seo.php index 63b6b92..949b133 100644 --- a/config/seo.php +++ b/config/seo.php @@ -59,7 +59,7 @@ return [ * * You can use the following filetypes: ico, png, gif, jpeg, svg. */ - 'favicon' => '/img/favicon.svg', + 'favicon' => '/favicon.ico', 'title' => [ /** diff --git a/database/factories/CityFactory.php b/database/factories/CityFactory.php new file mode 100644 index 0000000..d1e00f3 --- /dev/null +++ b/database/factories/CityFactory.php @@ -0,0 +1,27 @@ + + */ +class CityFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->city(), + 'country_id' => 1, + 'longitude' => fake()->longitude(), + 'latitude' => fake()->latitude(), + 'created_by' => \App\Models\User::factory(), + ]; + } +} diff --git a/database/factories/CountryFactory.php b/database/factories/CountryFactory.php new file mode 100644 index 0000000..24ce59c --- /dev/null +++ b/database/factories/CountryFactory.php @@ -0,0 +1,25 @@ + + */ +class CountryFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->country(), + 'code' => fake()->countryCode(), + 'language_codes' => ['en'], + ]; + } +} diff --git a/database/factories/MeetupFactory.php b/database/factories/MeetupFactory.php new file mode 100644 index 0000000..f70233f --- /dev/null +++ b/database/factories/MeetupFactory.php @@ -0,0 +1,28 @@ + + */ +class MeetupFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $name = fake()->company(); + + return [ + 'name' => $name, + 'slug' => \Illuminate\Support\Str::slug($name), + 'github_data' => [], + 'created_by' => \App\Models\User::factory(), + ]; + } +} diff --git a/database/migrations/2026_01_17_163021_add_recurrence_rules_to_meetup_events_table.php b/database/migrations/2026_01_17_163021_add_recurrence_rules_to_meetup_events_table.php new file mode 100644 index 0000000..7edb28e --- /dev/null +++ b/database/migrations/2026_01_17_163021_add_recurrence_rules_to_meetup_events_table.php @@ -0,0 +1,38 @@ +string('recurrence_type')->nullable()->after('start'); + $table->string('recurrence_day_of_week')->nullable()->after('recurrence_type'); + $table->string('recurrence_day_position')->nullable()->after('recurrence_day_of_week'); + $table->unsignedInteger('recurrence_interval')->default(1)->after('recurrence_day_position'); + $table->dateTime('recurrence_end_date')->nullable()->after('recurrence_interval'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('meetup_events', function (Blueprint $table) { + $table->dropColumn([ + 'recurrence_type', + 'recurrence_day_of_week', + 'recurrence_day_position', + 'recurrence_interval', + 'recurrence_end_date', + ]); + }); + } +}; diff --git a/phpunit.xml b/phpunit.xml index c09b5bc..6fe7020 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -11,6 +11,9 @@ tests/Feature + + resources/views + diff --git a/resources/views/livewire/meetups/create-edit-events.blade.php b/resources/views/livewire/meetups/create-edit-events.blade.php index 4d6453e..a907084 100644 --- a/resources/views/livewire/meetups/create-edit-events.blade.php +++ b/resources/views/livewire/meetups/create-edit-events.blade.php @@ -1,6 +1,7 @@ user()->timezone ?? 'Europe/Berlin'; + // Ensure timezone is always set - use fallback if not initialized yet + $timezone = $this->userTimezone ?: (auth()->user()->timezone ?? 'Europe/Berlin'); $startDate = \Carbon\Carbon::createFromFormat('Y-m-d H:i', $this->startDate . ' ' . $this->startTime, $timezone); $endDate = \Carbon\Carbon::createFromFormat('Y-m-d', $this->endDate, $timezone); - $dates = []; + // Use custom recurrence when dayOfWeek and dayPosition are set (e.g., "last Friday of month") + if ($this->recurrenceDayOfWeek && $this->recurrenceDayPosition) { + return $this->generateCustomRecurrenceDates($startDate, $endDate, $timezone, true); + } + + // For weekly recurrence with a specific day of week (no position), + // shift start date to the next occurrence of that weekday + if ($this->recurrenceType === RecurrenceType::Weekly && $this->recurrenceDayOfWeek) { + $dayOfWeekNumber = $this->getDayOfWeekNumber($this->recurrenceDayOfWeek); + if ($dayOfWeekNumber !== null) { + $adjustedStartDate = $startDate->copy(); + // Find the next occurrence of the specified weekday + while ($adjustedStartDate->dayOfWeek !== $dayOfWeekNumber) { + $adjustedStartDate->addDay(); + } + // Generate weekly dates from the adjusted start + $dates = []; + $currentDate = $adjustedStartDate->copy(); + while ($currentDate->lessThanOrEqualTo($endDate) && count($dates) < 100) { + $dates[] = [ + 'date' => $currentDate->copy(), + 'formatted' => $currentDate->translatedFormat('l, d.m.Y'), + 'time' => $currentDate->format('H:i'), + ]; + $currentDate->addWeek(); + } + return $dates; + } + } + + // Default: generate dates based on recurrence type $currentDate = $startDate->copy(); + $dates = []; while ($currentDate->lessThanOrEqualTo($endDate) && count($dates) < 100) { $dates[] = [ @@ -45,7 +84,7 @@ class extends Component { 'time' => $currentDate->format('H:i'), ]; - if ($this->interval === 'weekly') { + if ($this->recurrenceType === RecurrenceType::Weekly) { $currentDate->addWeek(); } else { $currentDate->addMonth(); @@ -58,6 +97,138 @@ class extends Component { } } + private function generateCustomRecurrenceDates(\Carbon\Carbon $startDate, \Carbon\Carbon $endDate, string $timezone, bool $formatted = false): array + { + $dates = []; + + // Start from the beginning of the month containing startDate + $currentDate = $startDate->copy()->startOfMonth(); + // Preserve the time from startDate for the occurrences + $time = $startDate->format('H:i:s'); + + while ($currentDate->lessThanOrEqualTo($endDate) && count($dates) < 100) { + $occurrenceDate = $this->findNextOccurrence($currentDate, $timezone); + + if ($occurrenceDate && $occurrenceDate->lessThanOrEqualTo($endDate)) { + // Set the time from startDate, preserving the date + $occurrenceWithTime = $occurrenceDate->copy()->setTimeFrom($startDate); + + // Only add if this is after or on the start date + if ($occurrenceWithTime->gte($startDate)) { + if ($formatted) { + $dates[] = [ + 'date' => $occurrenceWithTime, + 'formatted' => $occurrenceWithTime->translatedFormat('l, d.m.Y'), + 'time' => $time, + ]; + } else { + $dates[] = $occurrenceWithTime; + } + } + + // Move to the next month + $currentDate = $currentDate->copy()->addMonth(); + } else { + break; + } + } + + return $dates; + } + + private function findNextOccurrence(\Carbon\Carbon $currentDate, string $timezone): ?\Carbon\Carbon + { + if (!$this->recurrenceDayOfWeek || !$this->recurrenceDayPosition) { + return $currentDate; + } + + $dayOfWeek = $this->getDayOfWeekNumber($this->recurrenceDayOfWeek); + $dayPosition = $this->getDayPositionNumber($this->recurrenceDayPosition); + + if ($dayOfWeek === null || $dayPosition === null) { + return $currentDate; + } + + // Find the Nth dayOfWeek in the current month + $date = $currentDate->copy()->startOfMonth(); + + if ($dayPosition === -1) { + return $date->lastOfMonth($dayOfWeek)->setTime($currentDate->hour, $currentDate->minute, $currentDate->second); + } + + $count = 0; + while ($date->month === $currentDate->month) { + if ($date->dayOfWeek === $dayOfWeek) { + $count++; + if ($count === $dayPosition) { + return $date->copy()->setTime($currentDate->hour, $currentDate->minute, $currentDate->second); + } + } + $date->addDay(); + } + + // If we didn't find enough occurrences in this month, return null + return null; + } + + private function getDayOfWeekNumber(string $day): ?int + { + return match (strtolower($day)) { + 'monday', 'montag' => \Carbon\Carbon::MONDAY, + 'tuesday', 'dienstag' => \Carbon\Carbon::TUESDAY, + 'wednesday', 'mittwoch' => \Carbon\Carbon::WEDNESDAY, + 'thursday', 'donnerstag' => \Carbon\Carbon::THURSDAY, + 'friday', 'freitag' => \Carbon\Carbon::FRIDAY, + 'saturday', 'samstag' => \Carbon\Carbon::SATURDAY, + 'sunday', 'sonntag' => \Carbon\Carbon::SUNDAY, + default => null, + }; + } + + private function getDayPositionNumber(string $position): ?int + { + return match (strtolower($position)) { + 'first', 'erster' => 1, + 'second', 'zweiter' => 2, + 'third', 'dritter' => 3, + 'fourth', 'vierter' => 4, + 'last', 'letzter' => -1, + default => null, + }; + } + + public function getRecurrenceTypesProperty(): array + { + return [ + RecurrenceType::Weekly, + RecurrenceType::Monthly, + ]; + } + + public function getDaysOfWeekProperty(): array + { + return [ + 'monday' => __('Montag'), + 'tuesday' => __('Dienstag'), + 'wednesday' => __('Mittwoch'), + 'thursday' => __('Donnerstag'), + 'friday' => __('Freitag'), + 'saturday' => __('Samstag'), + 'sunday' => __('Sonntag'), + ]; + } + + public function getDayPositionsProperty(): array + { + return [ + 'first' => __('Erster'), + 'second' => __('Zweiter'), + 'third' => __('Dritter'), + 'fourth' => __('Vierter'), + 'last' => __('Letzter'), + ]; + } + #[Validate('required|string|max:255')] public ?string $location = null; @@ -70,7 +241,8 @@ class extends Component { public function mount(): void { $this->country = request()->route('country', config('app.domain_country')); - $timezone = auth()->user()->timezone ?? 'Europe/Berlin'; + $this->userTimezone = auth()->user()->timezone ?? 'Europe/Berlin'; + $timezone = $this->userTimezone; if ($this->event) { $localStart = $this->event->start->setTimezone($timezone); @@ -79,12 +251,21 @@ class extends Component { $this->location = $this->event->location; $this->description = $this->event->description; $this->link = $this->event->link; + + if ($this->event->recurrence_type) { + $this->seriesMode = true; + $this->recurrenceType = $this->event->recurrence_type; + $this->recurrenceDayOfWeek = $this->event->recurrence_day_of_week; + $this->recurrenceDayPosition = $this->event->recurrence_day_position; + $this->endDate = $this->event->recurrence_end_date ? $this->event->recurrence_end_date->format('Y-m-d') : ''; + } } else { // Set default start time to next Monday at 19:00 in user's timezone $defaultStart = now($timezone)->next('Monday')->setTime(19, 0); $this->startDate = $defaultStart->format('Y-m-d'); $this->startTime = $defaultStart->format('H:i'); $this->endDate = $defaultStart->copy()->addMonths(6)->format('Y-m-d'); + $this->recurrenceType = RecurrenceType::Weekly; } } @@ -100,12 +281,12 @@ class extends Component { if ($this->seriesMode) { $validationRules['endDate'] = 'required|date|after:startDate'; - $validationRules['interval'] = 'required|in:weekly,monthly'; + $validationRules['recurrenceType'] = 'required'; } $this->validate($validationRules); - $timezone = auth()->user()->timezone ?? 'Europe/Berlin'; + $timezone = $this->userTimezone; if ($this->seriesMode && !$this->event) { // Create series of events @@ -153,11 +334,12 @@ class extends Component { $startDate = \Carbon\Carbon::createFromFormat('Y-m-d H:i', $this->startDate . ' ' . $this->startTime, $timezone); $endDate = \Carbon\Carbon::createFromFormat('Y-m-d', $this->endDate, $timezone); - $currentDate = $startDate->copy(); $eventsCreated = 0; - while ($currentDate->lessThanOrEqualTo($endDate)) { - $utcDateTime = $currentDate->copy()->setTimezone('UTC'); + $dates = $this->generateEventDates($startDate, $endDate, $timezone); + + foreach ($dates as $date) { + $utcDateTime = $date->copy()->setTimezone('UTC'); $this->meetup->meetupEvents()->create([ 'start' => $utcDateTime, @@ -170,16 +352,52 @@ class extends Component { ]); $eventsCreated++; + } - // Move to next date based on interval - if ($this->interval === 'weekly') { + session()->flash('status', __(':count Events erfolgreich erstellt!', ['count' => $eventsCreated])); + } + + private function generateEventDates(\Carbon\Carbon $startDate, \Carbon\Carbon $endDate, string $timezone): array + { + // Use custom recurrence when dayOfWeek and dayPosition are set (e.g., "last Friday of month") + if ($this->recurrenceDayOfWeek && $this->recurrenceDayPosition) { + return $this->generateCustomRecurrenceDates($startDate, $endDate, $timezone); + } + + // For weekly recurrence with a specific day of week (no position), + // shift start date to the next occurrence of that weekday + if ($this->recurrenceType === RecurrenceType::Weekly && $this->recurrenceDayOfWeek) { + $dayOfWeekNumber = $this->getDayOfWeekNumber($this->recurrenceDayOfWeek); + if ($dayOfWeekNumber !== null) { + $adjustedStartDate = $startDate->copy(); + while ($adjustedStartDate->dayOfWeek !== $dayOfWeekNumber) { + $adjustedStartDate->addDay(); + } + $dates = []; + $currentDate = $adjustedStartDate->copy(); + while ($currentDate->lessThanOrEqualTo($endDate)) { + $dates[] = $currentDate->copy(); + $currentDate->addWeek(); + } + return $dates; + } + } + + // Default: generate dates based on recurrence type + $dates = []; + $currentDate = $startDate->copy(); + + while ($currentDate->lessThanOrEqualTo($endDate)) { + $dates[] = $currentDate->copy(); + + if ($this->recurrenceType === RecurrenceType::Weekly) { $currentDate->addWeek(); } else { $currentDate->addMonth(); } } - session()->flash('status', __(':count Events erfolgreich erstellt!', ['count' => $eventsCreated])); + return $dates; } public function delete(): void @@ -217,15 +435,15 @@ class extends Component {
{{ $seriesMode ? __('Startdatum') : __('Datum') }} * - + {{ $seriesMode ? __('Datum des ersten Termins') : __('An welchem Tag findet das Event statt?') }} {{ __('Uhrzeit') }} * - - {{ __('Um wie viel Uhr startet das Event?') }} ({{ auth()->user()->timezone ?? 'Europe/Berlin' }}) + + {{ __('Um wie viel Uhr startet das Event?') }} ({{ $this->userTimezone }})
@@ -234,21 +452,57 @@ class extends Component {
{{ __('Enddatum') }} * - + {{ __('Datum des letzten Termins') }} - {{ __('Intervall') }} * - - - + {{ __('Wiederholungstyp') }} * + + @foreach($this->recurrenceTypes as $type) + {{ $type->getLabel() }} + @endforeach {{ __('Wie oft soll das Event wiederholt werden?') }} - +
+ +
+ + {{ __('Wochentag') }} + + {{ __('Automatisch (wie Startdatum)') }} + @foreach($this->daysOfWeek as $key => $label) + {{ $label }} + @endforeach + + {{ __('An welchem Wochentag soll das Event stattfinden?') }} + + + + + {{ __('Position im Monat') }} + + {{ __('Automatisch (gleiches Datum)') }} + @foreach($this->dayPositions as $key => $label) + {{ $label }} + @endforeach + + {{ __('Welcher Wochentag im Monat? (z.B. "letzter Freitag")') }} + + +
+ + + + {{ __('Für regelmäßige Termine wie "immer am letzten Freitag des Monats":') }}
+ • {{ __('Wiederholungstyp: Monatlich') }}
+ • {{ __('Wochentag: Freitag') }}
+ • {{ __('Position im Monat: Letzter') }} +
+
@endif @@ -287,7 +541,7 @@ class extends Component { @foreach($this->previewDates as $index => $dateInfo)
-
+
{{ $index + 1 }}
@@ -346,7 +600,7 @@ class extends Component { - +
{{ __('Serientermine erstellen?') }} diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php deleted file mode 100644 index 12f2139..0000000 --- a/tests/Feature/Auth/AuthenticationTest.php +++ /dev/null @@ -1,48 +0,0 @@ -get('/login'); - - $response->assertStatus(200); -}); - -test('users can authenticate using the login screen', function () { - $user = User::factory()->create(); - - $response = LivewireVolt::test('auth.login') - ->set('email', $user->email) - ->set('password', 'password') - ->call('login'); - - $response - ->assertHasNoErrors() - ->assertRedirect(route('dashboard', absolute: false)); - - $this->assertAuthenticated(); -}); - -test('users can not authenticate with invalid password', function () { - $user = User::factory()->create(); - - $response = LivewireVolt::test('auth.login') - ->set('email', $user->email) - ->set('password', 'wrong-password') - ->call('login'); - - $response->assertHasErrors('email'); - - $this->assertGuest(); -}); - -test('users can logout', function () { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->post('/logout'); - - $response->assertRedirect('/'); - - $this->assertGuest(); -}); \ No newline at end of file diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php deleted file mode 100644 index a2294e8..0000000 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ /dev/null @@ -1,47 +0,0 @@ -unverified()->create(); - - $response = $this->actingAs($user)->get('/verify-email'); - - $response->assertStatus(200); -}); - -test('email can be verified', function () { - $user = User::factory()->unverified()->create(); - - Event::fake(); - - $verificationUrl = URL::temporarySignedRoute( - 'verification.verify', - now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1($user->email)] - ); - - $response = $this->actingAs($user)->get($verificationUrl); - - Event::assertDispatched(Verified::class); - - expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); - $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); -}); - -test('email is not verified with invalid hash', function () { - $user = User::factory()->unverified()->create(); - - $verificationUrl = URL::temporarySignedRoute( - 'verification.verify', - now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1('wrong-email')] - ); - - $this->actingAs($user)->get($verificationUrl); - - expect($user->fresh()->hasVerifiedEmail())->toBeFalse(); -}); \ No newline at end of file diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php deleted file mode 100644 index 7edf020..0000000 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ /dev/null @@ -1,38 +0,0 @@ -create(); - - $response = $this->actingAs($user)->get('/confirm-password'); - - $response->assertStatus(200); -}); - -test('password can be confirmed', function () { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Volt::test('auth.confirm-password') - ->set('password', 'password') - ->call('confirmPassword'); - - $response - ->assertHasNoErrors() - ->assertRedirect(route('dashboard', absolute: false)); -}); - -test('password is not confirmed with invalid password', function () { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Volt::test('auth.confirm-password') - ->set('password', 'wrong-password') - ->call('confirmPassword'); - - $response->assertHasErrors(['password']); -}); \ No newline at end of file diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php deleted file mode 100644 index 53e2f62..0000000 --- a/tests/Feature/Auth/PasswordResetTest.php +++ /dev/null @@ -1,66 +0,0 @@ -get('/forgot-password'); - - $response->assertStatus(200); -}); - -test('reset password link can be requested', function () { - Notification::fake(); - - $user = User::factory()->create(); - - Volt::test('auth.forgot-password') - ->set('email', $user->email) - ->call('sendPasswordResetLink'); - - Notification::assertSentTo($user, ResetPassword::class); -}); - -test('reset password screen can be rendered', function () { - Notification::fake(); - - $user = User::factory()->create(); - - Volt::test('auth.forgot-password') - ->set('email', $user->email) - ->call('sendPasswordResetLink'); - - Notification::assertSentTo($user, ResetPassword::class, function ($notification) { - $response = $this->get('/reset-password/'.$notification->token); - - $response->assertStatus(200); - - return true; - }); -}); - -test('password can be reset with valid token', function () { - Notification::fake(); - - $user = User::factory()->create(); - - Volt::test('auth.forgot-password') - ->set('email', $user->email) - ->call('sendPasswordResetLink'); - - Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { - $response = Volt::test('auth.reset-password', ['token' => $notification->token]) - ->set('email', $user->email) - ->set('password', 'password') - ->set('password_confirmation', 'password') - ->call('resetPassword'); - - $response - ->assertHasNoErrors() - ->assertRedirect(route('login', absolute: false)); - - return true; - }); -}); \ No newline at end of file diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php deleted file mode 100644 index 3db4e72..0000000 --- a/tests/Feature/Auth/RegistrationTest.php +++ /dev/null @@ -1,24 +0,0 @@ -get('/register'); - - $response->assertStatus(200); -}); - -test('new users can register', function () { - $response = Volt::test('auth.register') - ->set('name', 'Test User') - ->set('email', 'test@example.com') - ->set('password', 'password') - ->set('password_confirmation', 'password') - ->call('register'); - - $response - ->assertHasNoErrors() - ->assertRedirect(route('dashboard', absolute: false)); - - $this->assertAuthenticated(); -}); \ No newline at end of file diff --git a/tests/Feature/CreateEditEventsSeriesTest.php b/tests/Feature/CreateEditEventsSeriesTest.php new file mode 100644 index 0000000..fecece5 --- /dev/null +++ b/tests/Feature/CreateEditEventsSeriesTest.php @@ -0,0 +1,195 @@ +user = User::factory()->create(['timezone' => 'Europe/Berlin']); + $this->country = Country::factory()->create(); + $this->city = City::factory()->for($this->country)->create(); + $this->meetup = Meetup::factory()->for($this->city)->create(); +}); + +it('creates a weekly recurring series of events', function () { + Livewire::actingAs($this->user) + ->test('meetups.create-edit-events', ['meetup' => $this->meetup]) + ->set('seriesMode', true) + ->set('startDate', '2026-01-19') + ->set('startTime', '19:00') + ->set('endDate', '2026-02-14') + ->set('recurrenceType', RecurrenceType::Weekly) + ->set('location', 'Test Location') + ->set('description', 'Test Description') + ->set('link', 'https://example.com') + ->call('save') + ->assertHasNoErrors(); + + assertDatabaseCount('meetup_events', 4); +}); + +it('creates a monthly recurring series of events', function () { + Livewire::actingAs($this->user) + ->test('meetups.create-edit-events', ['meetup' => $this->meetup]) + ->set('seriesMode', true) + ->set('startDate', '2026-01-19') + ->set('startTime', '19:00') + ->set('endDate', '2026-03-31') + ->set('recurrenceType', RecurrenceType::Monthly) + ->set('location', 'Test Location') + ->set('description', 'Test Description') + ->set('link', 'https://example.com') + ->call('save') + ->assertHasNoErrors(); + + assertDatabaseCount('meetup_events', 3); +}); + +it('creates a series for last Friday of each month', function () { + Livewire::actingAs($this->user) + ->test('meetups.create-edit-events', ['meetup' => $this->meetup]) + ->set('seriesMode', true) + ->set('startDate', '2026-01-01') + ->set('startTime', '19:00') + ->set('endDate', '2026-03-31') + ->set('recurrenceType', RecurrenceType::Monthly) + ->set('recurrenceDayOfWeek', 'friday') + ->set('recurrenceDayPosition', 'last') + ->set('location', 'Test Location') + ->set('description', 'Test Description') + ->set('link', 'https://example.com') + ->call('save') + ->assertHasNoErrors(); + + assertDatabaseCount('meetup_events', 3); + + $events = $this->meetup->meetupEvents()->get(); + + expect($events[0]->start->format('Y-m-d'))->toBe('2026-01-30') + ->and($events[1]->start->format('Y-m-d'))->toBe('2026-02-27') + ->and($events[2]->start->format('Y-m-d'))->toBe('2026-03-27'); +}); + +it('creates a series for first Monday of each month', function () { + Livewire::actingAs($this->user) + ->test('meetups.create-edit-events', ['meetup' => $this->meetup]) + ->set('seriesMode', true) + ->set('startDate', '2026-01-01') + ->set('startTime', '19:00') + ->set('endDate', '2026-03-31') + ->set('recurrenceType', RecurrenceType::Monthly) + ->set('recurrenceDayOfWeek', 'monday') + ->set('recurrenceDayPosition', 'first') + ->set('location', 'Test Location') + ->set('description', 'Test Description') + ->set('link', 'https://example.com') + ->call('save') + ->assertHasNoErrors(); + + assertDatabaseCount('meetup_events', 3); + + $events = $this->meetup->meetupEvents()->get(); + + expect($events[0]->start->format('Y-m-d'))->toBe('2026-01-05') + ->and($events[1]->start->format('Y-m-d'))->toBe('2026-02-02') + ->and($events[2]->start->format('Y-m-d'))->toBe('2026-03-02'); +}); + +it('creates first Friday series when start date is Saturday', function () { + Livewire::actingAs($this->user) + ->test('meetups.create-edit-events', ['meetup' => $this->meetup]) + ->set('seriesMode', true) + ->set('startDate', '2026-01-17') // Saturday + ->set('startTime', '19:00') + ->set('endDate', '2026-04-30') + ->set('recurrenceType', RecurrenceType::Monthly) + ->set('recurrenceDayOfWeek', 'friday') + ->set('recurrenceDayPosition', 'first') + ->set('location', 'Test Location') + ->set('description', 'Test Description') + ->set('link', 'https://example.com') + ->call('save') + ->assertHasNoErrors(); + + assertDatabaseCount('meetup_events', 3); + + $events = $this->meetup->meetupEvents()->get(); + + expect($events[0]->start->format('Y-m-d'))->toBe('2026-02-06') + ->and($events[1]->start->format('Y-m-d'))->toBe('2026-03-06') + ->and($events[2]->start->format('Y-m-d'))->toBe('2026-04-03'); +}); + +it('updates preview when recurrenceDayOfWeek is changed for weekly recurrence', function () { + $component = Livewire::actingAs($this->user) + ->test('meetups.create-edit-events', ['meetup' => $this->meetup]) + ->set('seriesMode', true) + ->set('startDate', '2026-01-19') // Monday + ->set('startTime', '19:00') + ->set('endDate', '2026-02-28') + ->set('recurrenceType', RecurrenceType::Weekly) + ->set('recurrenceDayOfWeek', 'tuesday') // Change to Tuesday + ->set('location', 'Test Location') + ->set('description', 'Test Description') + ->set('link', 'https://example.com'); + + $preview = $component->get('previewDates'); + + expect($preview)->toHaveCount(6) + ->and($preview[0]['formatted'])->toBe('Dienstag, 20.01.2026') + ->and($preview[1]['formatted'])->toBe('Dienstag, 27.01.2026') + ->and($preview[2]['formatted'])->toBe('Dienstag, 03.02.2026') + ->and($preview[3]['formatted'])->toBe('Dienstag, 10.02.2026') + ->and($preview[4]['formatted'])->toBe('Dienstag, 17.02.2026') + ->and($preview[5]['formatted'])->toBe('Dienstag, 24.02.2026'); +}); + +it('validates required fields when creating a series', function () { + Livewire::actingAs($this->user) + ->test('meetups.create-edit-events', ['meetup' => $this->meetup]) + ->set('seriesMode', true) + ->set('startDate', '') + ->set('startTime', '') + ->set('endDate', '') + ->set('recurrenceType', null) + ->set('location', '') + ->set('description', '') + ->set('link', '') + ->call('save') + ->assertHasErrors([ + 'startDate', + 'startTime', + 'endDate', + 'recurrenceType', + 'location', + 'description', + 'link', + ]); +}); + +it('shows correct preview for first Friday when start date is Saturday', function () { + $component = Livewire::actingAs($this->user) + ->test('meetups.create-edit-events', ['meetup' => $this->meetup]) + ->set('seriesMode', true) + ->set('startDate', '2026-01-17') // Saturday + ->set('startTime', '19:00') + ->set('endDate', '2026-04-30') + ->set('recurrenceType', RecurrenceType::Monthly) + ->set('recurrenceDayOfWeek', 'friday') + ->set('recurrenceDayPosition', 'first') + ->set('location', 'Test Location') + ->set('description', 'Test Description') + ->set('link', 'https://example.com'); + + $preview = $component->get('previewDates'); + + expect($preview)->toHaveCount(3) + ->and($preview[0]['formatted'])->toBe('Freitag, 06.02.2026') + ->and($preview[1]['formatted'])->toBe('Freitag, 06.03.2026') + ->and($preview[2]['formatted'])->toBe('Freitag, 03.04.2026'); +}); diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php deleted file mode 100644 index 437eb09..0000000 --- a/tests/Feature/DashboardTest.php +++ /dev/null @@ -1,16 +0,0 @@ -get('/dashboard'); - $response->assertRedirect('/login'); -}); - -test('authenticated users can visit the dashboard', function () { - $user = User::factory()->create(); - $this->actingAs($user); - - $response = $this->get('/dashboard'); - $response->assertStatus(200); -}); \ No newline at end of file diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index b63b781..0000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,7 +0,0 @@ -get('/'); - - $response->assertStatus(200); -}); \ No newline at end of file diff --git a/tests/Feature/Livewire/Country/ChooserTest.php b/tests/Feature/Livewire/Country/ChooserTest.php deleted file mode 100644 index 69990f8..0000000 --- a/tests/Feature/Livewire/Country/ChooserTest.php +++ /dev/null @@ -1,9 +0,0 @@ -assertSee(''); -}); diff --git a/tests/Feature/Livewire/Meetup.indexTest.php b/tests/Feature/Livewire/Meetup.indexTest.php deleted file mode 100644 index 8d12730..0000000 --- a/tests/Feature/Livewire/Meetup.indexTest.php +++ /dev/null @@ -1,9 +0,0 @@ -assertSee(''); -}); diff --git a/tests/Feature/Livewire/Meetups/EditTest.php b/tests/Feature/Livewire/Meetups/EditTest.php deleted file mode 100644 index b0e5d03..0000000 --- a/tests/Feature/Livewire/Meetups/EditTest.php +++ /dev/null @@ -1,9 +0,0 @@ -assertSee(''); -}); diff --git a/tests/Feature/Livewire/Meetups/MapTest.php b/tests/Feature/Livewire/Meetups/MapTest.php deleted file mode 100644 index b94bf86..0000000 --- a/tests/Feature/Livewire/Meetups/MapTest.php +++ /dev/null @@ -1,9 +0,0 @@ -assertSee(''); -}); diff --git a/tests/Feature/Livewire/Services/CreateTest.php b/tests/Feature/Livewire/Services/CreateTest.php deleted file mode 100644 index 1b1ffa1..0000000 --- a/tests/Feature/Livewire/Services/CreateTest.php +++ /dev/null @@ -1,25 +0,0 @@ -create(); - - $component = Volt::test('services.create') - ->actingAs($user) - ->set('name', 'My Node') - ->set('type', SelfHostedServiceType::Mempool->value) - ->set('url_clearnet', 'https://example.com') - ->set('contact', ['url' => 'https://contact.example.com']) - ->call('save'); - - expect(SelfHostedService::where('name', 'My Node')->exists())->toBeTrue(); - - $service = SelfHostedService::where('name', 'My Node')->first(); - expect($service->getFirstMedia('logo'))->toBeNull(); -}); diff --git a/tests/Feature/Livewire/Services/IndexTest.php b/tests/Feature/Livewire/Services/IndexTest.php deleted file mode 100644 index 54ebc84..0000000 --- a/tests/Feature/Livewire/Services/IndexTest.php +++ /dev/null @@ -1,15 +0,0 @@ -count(2)->create(); - - $component = Volt::test('services.index'); - - $component->assertStatus(200) - ->assertSee('Self Hosted Services'); -}); diff --git a/tests/Feature/Livewire/WelcomeTest.php b/tests/Feature/Livewire/WelcomeTest.php deleted file mode 100644 index 01900e7..0000000 --- a/tests/Feature/Livewire/WelcomeTest.php +++ /dev/null @@ -1,9 +0,0 @@ -assertSee(''); -}); diff --git a/tests/Feature/Settings/PasswordUpdateTest.php b/tests/Feature/Settings/PasswordUpdateTest.php deleted file mode 100644 index 2af6939..0000000 --- a/tests/Feature/Settings/PasswordUpdateTest.php +++ /dev/null @@ -1,39 +0,0 @@ -create([ - 'password' => Hash::make('password'), - ]); - - $this->actingAs($user); - - $response = Volt::test('settings.password') - ->set('current_password', 'password') - ->set('password', 'new-password') - ->set('password_confirmation', 'new-password') - ->call('updatePassword'); - - $response->assertHasNoErrors(); - - expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue(); -}); - -test('correct password must be provided to update password', function () { - $user = User::factory()->create([ - 'password' => Hash::make('password'), - ]); - - $this->actingAs($user); - - $response = Volt::test('settings.password') - ->set('current_password', 'wrong-password') - ->set('password', 'new-password') - ->set('password_confirmation', 'new-password') - ->call('updatePassword'); - - $response->assertHasErrors(['current_password']); -}); \ No newline at end of file diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php deleted file mode 100644 index 44ec58d..0000000 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ /dev/null @@ -1,75 +0,0 @@ -actingAs($user = User::factory()->create()); - - $this->get('/settings/profile')->assertOk(); -}); - -test('profile information can be updated', function () { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Volt::test('settings.profile') - ->set('name', 'Test User') - ->set('email', 'test@example.com') - ->call('updateProfileInformation'); - - $response->assertHasNoErrors(); - - $user->refresh(); - - expect($user->name)->toEqual('Test User'); - expect($user->email)->toEqual('test@example.com'); - expect($user->email_verified_at)->toBeNull(); -}); - -test('email verification status is unchanged when email address is unchanged', function () { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Volt::test('settings.profile') - ->set('name', 'Test User') - ->set('email', $user->email) - ->call('updateProfileInformation'); - - $response->assertHasNoErrors(); - - expect($user->refresh()->email_verified_at)->not->toBeNull(); -}); - -test('user can delete their account', function () { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Volt::test('settings.delete-user-form') - ->set('password', 'password') - ->call('deleteUser'); - - $response - ->assertHasNoErrors() - ->assertRedirect('/'); - - expect($user->fresh())->toBeNull(); - expect(auth()->check())->toBeFalse(); -}); - -test('correct password must be provided to delete account', function () { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Volt::test('settings.delete-user-form') - ->set('password', 'wrong-password') - ->call('deleteUser'); - - $response->assertHasErrors(['password']); - - expect($user->fresh())->not->toBeNull(); -}); \ No newline at end of file diff --git a/tests/Pest.php b/tests/Pest.php index 40d096b..a456b57 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -13,7 +13,7 @@ pest()->extend(Tests\TestCase::class) ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) - ->in('Feature'); + ->in('Feature', '../resources/views'); /* |--------------------------------------------------------------------------