diff --git a/app/Console/Commands/Admin/UserCreateCommand.php b/app/Console/Commands/Admin/UserCreateCommand.php index 08d67d18..979278d7 100644 --- a/app/Console/Commands/Admin/UserCreateCommand.php +++ b/app/Console/Commands/Admin/UserCreateCommand.php @@ -22,7 +22,8 @@ class UserCreateCommand extends Command protected $signature = 'admin:user:create { name : The name of the user } { email : The email of the user } - { --ask-for-password : Ask for the password, otherwise the command will generate a random one }'; + { --ask-for-password : Ask for the password, otherwise the command will generate a random one } + { --verify-email : Verify the email address of the user }'; /** * The console command description. @@ -39,6 +40,7 @@ public function handle(): int $name = $this->argument('name'); $email = $this->argument('email'); $askForPassword = (bool) $this->option('ask-for-password'); + $verifyEmail = (bool) $this->option('verify-email'); if (User::query()->where('email', $email)->where('is_placeholder', '=', false)->exists()) { $this->error('User with email "'.$email.'" already exists.'); @@ -71,6 +73,10 @@ public function handle(): int throw new LogicException('User does not have an organization'); } + if ($verifyEmail) { + $user->markEmailAsVerified(); + } + $this->info('Created user "'.$name.'" ("'.$email.'")'); $this->line('ID: '.$user->getKey()); $this->line('Name: '.$name); diff --git a/app/Filament/Resources/ClientResource.php b/app/Filament/Resources/ClientResource.php index dee96260..ee43a4e4 100644 --- a/app/Filament/Resources/ClientResource.php +++ b/app/Filament/Resources/ClientResource.php @@ -60,8 +60,13 @@ public static function table(Table $table): Table ->defaultSort('created_at', 'desc') ->filters([ SelectFilter::make('organization') + ->label('Organization') ->relationship('organization', 'name') ->searchable(), + SelectFilter::make('organization_id') + ->label('Organization ID') + ->relationship('organization', 'id') + ->searchable(), ]) ->actions([ Tables\Actions\EditAction::make(), diff --git a/app/Filament/Resources/OrganizationInvitationResource/Pages/EditOrganizationInvitation.php b/app/Filament/Resources/OrganizationInvitationResource/Pages/EditOrganizationInvitation.php index 5011d0db..c1c6e76d 100644 --- a/app/Filament/Resources/OrganizationInvitationResource/Pages/EditOrganizationInvitation.php +++ b/app/Filament/Resources/OrganizationInvitationResource/Pages/EditOrganizationInvitation.php @@ -5,6 +5,7 @@ namespace App\Filament\Resources\OrganizationInvitationResource\Pages; use App\Filament\Resources\OrganizationInvitationResource; +use Filament\Actions; use Filament\Resources\Pages\EditRecord; class EditOrganizationInvitation extends EditRecord @@ -14,6 +15,8 @@ class EditOrganizationInvitation extends EditRecord protected function getHeaderActions(): array { return [ + Actions\DeleteAction::make() + ->icon('heroicon-m-trash'), ]; } } diff --git a/app/Filament/Resources/OrganizationResource/RelationManagers/UsersRelationManager.php b/app/Filament/Resources/OrganizationResource/RelationManagers/UsersRelationManager.php index d09b1057..d027d4dc 100644 --- a/app/Filament/Resources/OrganizationResource/RelationManagers/UsersRelationManager.php +++ b/app/Filament/Resources/OrganizationResource/RelationManagers/UsersRelationManager.php @@ -56,6 +56,7 @@ public function table(Table $table): Table ]) ->headerActions([ Tables\Actions\AttachAction::make() + ->recordTitle(fn (User $record): string => "{$record->name} ({$record->email})") ->form(fn (AttachAction $action): array => [ $action->getRecordSelect(), Select::make('role') diff --git a/app/Filament/Resources/ProjectResource.php b/app/Filament/Resources/ProjectResource.php index d1ef89ae..695c31e5 100644 --- a/app/Filament/Resources/ProjectResource.php +++ b/app/Filament/Resources/ProjectResource.php @@ -72,8 +72,13 @@ public static function table(Table $table): Table ]) ->filters([ SelectFilter::make('organization') + ->label('Organization') ->relationship('organization', 'name') ->searchable(), + SelectFilter::make('organization_id') + ->label('Organization ID') + ->relationship('organization', 'id') + ->searchable(), ]) ->defaultSort('created_at', 'desc') ->actions([ diff --git a/app/Filament/Resources/ReportResource.php b/app/Filament/Resources/ReportResource.php index 064bdcfa..cf141bf9 100644 --- a/app/Filament/Resources/ReportResource.php +++ b/app/Filament/Resources/ReportResource.php @@ -101,8 +101,13 @@ public static function table(Table $table): Table ->defaultSort('created_at', 'desc') ->filters([ SelectFilter::make('organization') + ->label('Organization') ->relationship('organization', 'name') ->searchable(), + SelectFilter::make('organization_id') + ->label('Organization ID') + ->relationship('organization', 'id') + ->searchable(), ]) ->actions([ Action::make('public-view') diff --git a/app/Filament/Resources/TagResource.php b/app/Filament/Resources/TagResource.php index 1f134f68..659f4755 100644 --- a/app/Filament/Resources/TagResource.php +++ b/app/Filament/Resources/TagResource.php @@ -60,8 +60,13 @@ public static function table(Table $table): Table ->defaultSort('created_at', 'desc') ->filters([ SelectFilter::make('organization') + ->label('Organization') ->relationship('organization', 'name') ->searchable(), + SelectFilter::make('organization_id') + ->label('Organization ID') + ->relationship('organization', 'id') + ->searchable(), ]) ->actions([ Tables\Actions\EditAction::make(), diff --git a/app/Filament/Resources/TaskResource.php b/app/Filament/Resources/TaskResource.php index d4be3651..78e63b31 100644 --- a/app/Filament/Resources/TaskResource.php +++ b/app/Filament/Resources/TaskResource.php @@ -61,8 +61,13 @@ public static function table(Table $table): Table ]) ->filters([ SelectFilter::make('organization') + ->label('Organization') ->relationship('organization', 'name') ->searchable(), + SelectFilter::make('organization_id') + ->label('Organization ID') + ->relationship('organization', 'id') + ->searchable(), ]) ->defaultSort('created_at', 'desc') ->actions([ diff --git a/app/Filament/Resources/TimeEntryResource.php b/app/Filament/Resources/TimeEntryResource.php index cd9a5ce9..ffd133b3 100644 --- a/app/Filament/Resources/TimeEntryResource.php +++ b/app/Filament/Resources/TimeEntryResource.php @@ -92,8 +92,13 @@ public static function table(Table $table): Table ]) ->filters([ SelectFilter::make('organization') + ->label('Organization') ->relationship('organization', 'name') ->searchable(), + SelectFilter::make('organization_id') + ->label('Organization ID') + ->relationship('organization', 'id') + ->searchable(), ]) ->defaultSort('created_at', 'desc') ->actions([ diff --git a/app/Service/Import/ImportDatabaseHelper.php b/app/Service/Import/ImportDatabaseHelper.php index 1ca251ce..6f6067b8 100644 --- a/app/Service/Import/ImportDatabaseHelper.php +++ b/app/Service/Import/ImportDatabaseHelper.php @@ -196,6 +196,7 @@ public function getCachedModels(): array if ($this->mapKeyToModel === null) { return []; } + return array_values($this->mapKeyToModel); } diff --git a/config/excel.php b/config/excel.php index 22ade426..02dc56f5 100644 --- a/config/excel.php +++ b/config/excel.php @@ -163,8 +163,8 @@ */ 'cells' => [ 'middleware' => [ - //\Maatwebsite\Excel\Middleware\TrimCellValue::class, - //\Maatwebsite\Excel\Middleware\ConvertEmptyCellValuesToNull::class, + // \Maatwebsite\Excel\Middleware\TrimCellValue::class, + // \Maatwebsite\Excel\Middleware\ConvertEmptyCellValuesToNull::class, ], ], diff --git a/tests/Unit/Console/Commands/Admin/OrganizationDeleteCommandTest.php b/tests/Unit/Console/Commands/Admin/OrganizationDeleteCommandTest.php index 5c4201f8..22198f7d 100644 --- a/tests/Unit/Console/Commands/Admin/OrganizationDeleteCommandTest.php +++ b/tests/Unit/Console/Commands/Admin/OrganizationDeleteCommandTest.php @@ -28,8 +28,10 @@ public function test_it_calls_the_deletion_service_with_the_organization(): void }); // Act - $this->artisan('admin:organization:delete', ['organization' => $organization->getKey()]) - ->expectsOutput("Deleting organization with ID {$organization->getKey()}") + $command = $this->artisan('admin:organization:delete', ['organization' => $organization->getKey()]); + + // Assert + $command->expectsOutput("Deleting organization with ID {$organization->getKey()}") ->expectsOutput("Organization with ID {$organization->getKey()} has been deleted.") ->assertExitCode(0); } @@ -40,9 +42,11 @@ public function test_it_fails_if_organization_does_not_exist(): void $organizationId = Str::uuid()->toString(); // Act - $this->artisan('admin:organization:delete', ['organization' => $organizationId]) - ->expectsOutput('Organization with ID '.$organizationId.' not found.') - ->assertExitCode(1); + $command = $this->artisan('admin:organization:delete', ['organization' => $organizationId]); + + // Assert + $command->expectsOutput('Organization with ID '.$organizationId.' not found.'); + $command->assertExitCode(1); } public function test_it_fails_if_organization_id_is_not_a_valid_uuid(): void @@ -51,8 +55,10 @@ public function test_it_fails_if_organization_id_is_not_a_valid_uuid(): void $organizationId = 'invalid-uuid'; // Act - $this->artisan('admin:organization:delete', ['organization' => $organizationId]) - ->expectsOutput('Organization ID must be a valid UUID.') + $command = $this->artisan('admin:organization:delete', ['organization' => $organizationId]); + + // Assert + $command->expectsOutput('Organization ID must be a valid UUID.') ->assertExitCode(1); } } diff --git a/tests/Unit/Console/Commands/Admin/UserCreateCommandCommandTest.php b/tests/Unit/Console/Commands/Admin/UserCreateCommandCommandTest.php new file mode 100644 index 00000000..1d822241 --- /dev/null +++ b/tests/Unit/Console/Commands/Admin/UserCreateCommandCommandTest.php @@ -0,0 +1,114 @@ +withoutMockingConsoleOutput()->artisan('admin:user:create', [ + 'name' => $name, + 'email' => $email, + ]); + + // Assert + $this->assertSame(Command::SUCCESS, $exitCode); + $output = Artisan::output(); + $this->assertStringContainsString('Created user "'.$name.'" ("'.$email.'")', $output); + $this->assertDatabaseHas(User::class, [ + 'name' => $name, + 'email' => $email, + 'email_verified_at' => null, + ]); + } + + public function test_created_user_is_verified_if_option_is_set(): void + { + // Arrange + $email = 'mail@testuser.test'; + $name = 'Test User'; + + // Act + $exitCode = $this->withoutMockingConsoleOutput()->artisan('admin:user:create', [ + 'name' => $name, + 'email' => $email, + '--verify-email' => true, + ]); + + // Assert + $this->assertSame(Command::SUCCESS, $exitCode); + $output = Artisan::output(); + $this->assertStringContainsString('Created user "'.$name.'" ("'.$email.'")', $output); + $this->assertDatabaseHas(User::class, [ + 'name' => $name, + 'email' => $email, + ]); + $user = User::where('email', $email)->first(); + $this->assertNotNull($user->email_verified_at); + } + + public function test_it_fails_if_user_with_email_already_exists(): void + { + // Arrange + $email = 'mail@testuser.test'; + $name = 'Test User'; + + User::factory()->create([ + 'email' => $email, + ]); + + // Act + $exitCode = $this->withoutMockingConsoleOutput()->artisan('admin:user:create', [ + 'name' => $name, + 'email' => $email, + ]); + + // Assert + $this->assertSame(Command::FAILURE, $exitCode); + $output = Artisan::output(); + $this->assertStringContainsString('User with email "'.$email.'" already exists.', $output); + } + + public function test_it_asks_for_password_if_option_is_set(): void + { + // Arrange + $email = 'mail@testuser.test'; + $name = 'Test User'; + + // Act + $this->artisan('admin:user:create', [ + 'name' => $name, + 'email' => $email, + '--ask-for-password' => true, + ]) + ->expectsQuestion('Enter the password', 'password') + ->assertExitCode(Command::SUCCESS); + + $this->assertDatabaseHas(User::class, [ + 'name' => $name, + 'email' => $email, + 'email_verified_at' => null, + ]); + $user = User::where('email', $email)->first(); + $this->assertNotNull($user->password); + $this->assertTrue(Hash::check('password', $user->password)); + } +} diff --git a/tests/Unit/Console/Commands/Admin/UserVerifyCommandTest.php b/tests/Unit/Console/Commands/Admin/UserVerifyCommandTest.php index bc0a6bdf..0efa985c 100644 --- a/tests/Unit/Console/Commands/Admin/UserVerifyCommandTest.php +++ b/tests/Unit/Console/Commands/Admin/UserVerifyCommandTest.php @@ -29,7 +29,6 @@ public function test_it_verifies_user_email(): void $command = $this->artisan('admin:user:verify', ['email' => $user->email]); // Assert - $command->expectsOutput('Start verifying user with email "'.$user->email.'"') ->expectsOutput('User with email "'.$user->email.'" has been verified.') ->assertExitCode(0); diff --git a/tests/Unit/Filament/Resources/OrganizationInvitationResourceTest.php b/tests/Unit/Filament/Resources/OrganizationInvitationResourceTest.php new file mode 100644 index 00000000..8b0994b8 --- /dev/null +++ b/tests/Unit/Filament/Resources/OrganizationInvitationResourceTest.php @@ -0,0 +1,78 @@ +withPersonalOrganization()->create([ + 'email' => 'admin@example.com', + ]); + + $this->actingAs($user); + } + + public function test_can_list_organization_invitations(): void + { + // Arrange + $user = User::factory()->create(); + $organization = Organization::factory()->withOwner($user)->create(); + $organizationInvitations = OrganizationInvitation::factory()->forOrganization($organization)->createMany(5); + + // Act + $response = Livewire::test(OrganizationInvitationResource\Pages\ListOrganizationInvitations::class); + + // Assert + $response->assertSuccessful(); + $response->assertCanSeeTableRecords($organizationInvitations); + } + + public function test_can_see_edit_page_of_organization_invitation(): void + { + // Arrange + $organization = Organization::factory()->create(); + $organizationInvitation = OrganizationInvitation::factory()->forOrganization($organization)->create(); + + // Act + $response = Livewire::test(OrganizationInvitationResource\Pages\EditOrganizationInvitation::class, [ + 'record' => $organizationInvitation->getKey(), + ]); + + // Assert + $response->assertSuccessful(); + } + + public function test_can_delete_a_organization_invitation(): void + { + // Arrange + $organization = Organization::factory()->create(); + $organizationInvitation = OrganizationInvitation::factory()->forOrganization($organization)->create(); + + // Act + $response = Livewire::test(OrganizationInvitationResource\Pages\EditOrganizationInvitation::class, [ + 'record' => $organizationInvitation->getKey(), + ])->callAction(DeleteAction::class); + + // Assert + $response->assertSuccessful(); + $this->assertDatabaseMissing(OrganizationInvitation::class, [ + 'id' => $organizationInvitation->getKey(), + ]); + } +} diff --git a/tests/Unit/Filament/Resources/OrganizationResourceTest.php b/tests/Unit/Filament/Resources/OrganizationResourceTest.php index ab0c4be5..6762e89b 100644 --- a/tests/Unit/Filament/Resources/OrganizationResourceTest.php +++ b/tests/Unit/Filament/Resources/OrganizationResourceTest.php @@ -6,6 +6,7 @@ use App\Filament\Resources\OrganizationResource; use App\Models\Organization; +use App\Models\OrganizationInvitation; use App\Models\User; use App\Service\DeletionService; use Illuminate\Support\Facades\Config; @@ -74,4 +75,41 @@ public function test_can_delete_a_organization(): void // Assert $response->assertSuccessful(); } + + public function test_can_list_related_users(): void + { + // Arrange + $organization = Organization::factory()->create(); + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + $organization->users()->attach($user1); + $organization->users()->attach($user2); + + // Act + $response = Livewire::test(OrganizationResource\RelationManagers\UsersRelationManager::class, [ + 'ownerRecord' => $organization, + 'pageClass' => OrganizationResource\Pages\EditOrganization::class, + ]); + + // Assert + $response->assertSuccessful(); + $response->assertCanSeeTableRecords($organization->users()->get()); + } + + public function test_can_list_related_invitations(): void + { + // Arrange + $organization = Organization::factory()->create(); + $organizationInvitations = OrganizationInvitation::factory()->forOrganization($organization)->createMany(5); + + // Act + $response = Livewire::test(OrganizationResource\RelationManagers\InvitationsRelationManager::class, [ + 'ownerRecord' => $organization, + 'pageClass' => OrganizationResource\Pages\EditOrganization::class, + ]); + + // Assert + $response->assertSuccessful(); + $response->assertCanSeeTableRecords($organizationInvitations); + } } diff --git a/tests/Unit/Filament/Resources/ReportResourceTest.php b/tests/Unit/Filament/Resources/ReportResourceTest.php new file mode 100644 index 00000000..9b5664da --- /dev/null +++ b/tests/Unit/Filament/Resources/ReportResourceTest.php @@ -0,0 +1,55 @@ +withPersonalOrganization()->create([ + 'email' => 'admin@example.com', + ]); + + $this->actingAs($user); + } + + public function test_can_list_reports(): void + { + // Arrange + $reports = Report::factory()->createMany(5); + + // Act + $response = Livewire::test(ReportResource\Pages\ListReports::class); + + // Assert + $response->assertSuccessful(); + $response->assertCanSeeTableRecords($reports); + } + + public function test_can_see_edit_page_of_report(): void + { + // Arrange + $report = Report::factory()->create(); + + // Act + $response = Livewire::test(ReportResource\Pages\EditReport::class, [ + 'record' => $report->getKey(), + ]); + + // Assert + $response->assertSuccessful(); + } +} diff --git a/tests/Unit/Filament/Resources/UserResourceTest.php b/tests/Unit/Filament/Resources/UserResourceTest.php index 1f5ced07..ca941e4f 100644 --- a/tests/Unit/Filament/Resources/UserResourceTest.php +++ b/tests/Unit/Filament/Resources/UserResourceTest.php @@ -7,6 +7,7 @@ use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers; use App\Filament\Resources\TimeEntryResource; use App\Filament\Resources\UserResource; +use App\Models\Organization; use App\Models\User; use App\Service\DeletionService; use Illuminate\Support\Facades\Config; @@ -54,6 +55,18 @@ public function test_can_see_edit_page_of_user(): void $response->assertSuccessful(); } + public function test_can_see_view_page_of_user(): void + { + // Arrange + $user = User::factory()->create(); + + // Act + $response = Livewire::test(UserResource\Pages\ViewUser::class, ['record' => $user->getKey()]); + + // Assert + $response->assertSuccessful(); + } + public function test_can_delete_a_user(): void { // Arrange @@ -91,4 +104,42 @@ public function test_delete_user_shows_error_notification_on_failure(): void $response->assertNotified(__('exceptions.api.can_not_delete_user_who_is_owner_of_organization_with_multiple_members')); $response->assertSuccessful(); } + + public function test_can_list_related_organizations(): void + { + // Arrange + $user = User::factory()->create(); + $ownedOrganization = Organization::factory()->withOwner($user)->create(); + $organization = Organization::factory()->create(); + + // Act + $response = Livewire::test(UserResource\RelationManagers\OrganizationsRelationManager::class, [ + 'ownerRecord' => $user, + 'pageClass' => UserResource\Pages\EditUser::class, + ]); + + // Assert + $response->assertSuccessful(); + $response->assertCanSeeTableRecords($user->organizations()->get()); + $response->assertCanNotSeeTableRecords($user->ownedTeams()->get()); + } + + public function test_can_list_related_owned_organizations(): void + { + // Arrange + $user = User::factory()->create(); + $ownedOrganization = Organization::factory()->withOwner($user)->create(); + $organization = Organization::factory()->create(); + + // Act + $response = Livewire::test(UserResource\RelationManagers\OwnedOrganizationsRelationManager::class, [ + 'ownerRecord' => $user, + 'pageClass' => UserResource\Pages\EditUser::class, + ]); + + // Assert + $response->assertSuccessful(); + $response->assertCanSeeTableRecords($user->ownedTeams()->get()); + $response->assertCanNotSeeTableRecords($user->organizations()->get()); + } } diff --git a/tests/Unit/Service/Import/ImportDatabaseHelperTest.php b/tests/Unit/Service/Import/ImportDatabaseHelperTest.php index ae4549bd..854d5a9f 100644 --- a/tests/Unit/Service/Import/ImportDatabaseHelperTest.php +++ b/tests/Unit/Service/Import/ImportDatabaseHelperTest.php @@ -229,4 +229,23 @@ public function test_get_model_by_id_caches_result(): void // Assert $this->assertSame($user->getKey(), $model1->getKey()); } + + public function test_get_cached_models_returns_all_models_where_the_helper_already_fetched_the_model(): void + { + // Arrange + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + $helper = new ImportDatabaseHelper(User::class, ['email'], true); + $helper->getModelById($user1->getKey()); + $helper->getModelById($user2->getKey()); + $helper->getModelById($user1->getKey()); + + // Act + $models = $helper->getCachedModels(); + + // Assert + $this->assertCount(2, $models); + $this->assertContains($user1->getKey(), collect($models)->pluck('id')->toArray()); + $this->assertContains($user2->getKey(), collect($models)->pluck('id')->toArray()); + } } diff --git a/tests/Unit/Service/TimezoneServiceTest.php b/tests/Unit/Service/TimezoneServiceTest.php index 638734d1..d6bc6253 100644 --- a/tests/Unit/Service/TimezoneServiceTest.php +++ b/tests/Unit/Service/TimezoneServiceTest.php @@ -29,7 +29,7 @@ public function test_get_timezones_returns_all_available_timezones(): void // Assert $this->assertIsArray($result); - $this->assertCount(419, $result); + $this->assertTrue(in_array(count($result), [418, 419], true)); $this->assertContains('Europe/Vienna', $result); $this->assertContains('Europe/Berlin', $result); $this->assertContains('Europe/London', $result);