diff --git a/phpunit.xml b/phpunit.xml
index 506b9a3..7bd9467 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -21,9 +21,10 @@
+
-
-
+
+
diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php
deleted file mode 100644
index 8b5843f..0000000
--- a/tests/Feature/ExampleTest.php
+++ /dev/null
@@ -1,7 +0,0 @@
-get('/');
-
- $response->assertStatus(200);
-});
diff --git a/tests/Feature/Filament/Auth/DashboardAccessTest.php b/tests/Feature/Filament/Auth/DashboardAccessTest.php
new file mode 100644
index 0000000..6a3a476
--- /dev/null
+++ b/tests/Feature/Filament/Auth/DashboardAccessTest.php
@@ -0,0 +1,18 @@
+get('/dashboard')
+ ->assertRedirect();
+ });
+
+ it('allows authenticated users to access the dashboard', function () {
+ $user = createUserWithRole(RolesEnum::EDITOR);
+
+ $this->actingAs($user)
+ ->get('/dashboard')
+ ->assertSuccessful();
+ });
+});
diff --git a/tests/Feature/Filament/Users/InviteUserActionTest.php b/tests/Feature/Filament/Users/InviteUserActionTest.php
new file mode 100644
index 0000000..19c0860
--- /dev/null
+++ b/tests/Feature/Filament/Users/InviteUserActionTest.php
@@ -0,0 +1,58 @@
+ 'Invited User',
+ 'email' => 'invited@example.com',
+ 'role' => RolesEnum::EDITOR->value,
+ ]);
+
+ $user = User::query()->where('email', 'invited@example.com')->first();
+
+ expect($user)->not->toBeNull()
+ ->and($user->name)->toBe('Invited User')
+ ->and($user->password)->toBeNull()
+ ->and($user->invite_token)->not->toBeEmpty()
+ ->and($user->hasRole(RolesEnum::EDITOR))->toBeTrue();
+
+ Mail::assertSent(InviteUser::class, function (InviteUser $mail) use ($user) {
+ return $mail->hasTo('invited@example.com')
+ && $mail->user->is($user);
+ });
+ });
+
+ it('can invite a user from the users list page', function () {
+ $admin = createUserWithRole(RolesEnum::ADMIN);
+
+ actingAsFilamentUser($admin);
+
+ Livewire::test(ListUsers::class)
+ ->callAction('invite-user', data: [
+ 'name' => 'Filament Invitee',
+ 'email' => 'filament-invitee@example.com',
+ 'role' => RolesEnum::ADMIN->value,
+ ])
+ ->assertNotified();
+
+ $user = User::query()->where('email', 'filament-invitee@example.com')->first();
+
+ expect($user)->not->toBeNull()
+ ->and($user->hasRole(RolesEnum::ADMIN))->toBeTrue();
+
+ Mail::assertSent(InviteUser::class);
+ });
+});
diff --git a/tests/Feature/Filament/Users/ListUsersTest.php b/tests/Feature/Filament/Users/ListUsersTest.php
new file mode 100644
index 0000000..0b7ee48
--- /dev/null
+++ b/tests/Feature/Filament/Users/ListUsersTest.php
@@ -0,0 +1,34 @@
+count(2)->create();
+
+ actingAsFilamentUser($admin);
+
+ Livewire::test(ListUsers::class)
+ ->assertSuccessful()
+ ->loadTable()
+ ->assertCanSeeTableRecords($users);
+ });
+
+ it('can search users by name', function () {
+ $admin = createUserWithRole(RolesEnum::ADMIN);
+ $matchingUser = User::factory()->create(['name' => 'Unique Search Name']);
+ $otherUser = User::factory()->create(['name' => 'Someone Else']);
+
+ actingAsFilamentUser($admin);
+
+ Livewire::test(ListUsers::class)
+ ->loadTable()
+ ->searchTable('Unique Search Name')
+ ->assertCanSeeTableRecords([$matchingUser])
+ ->assertCanNotSeeTableRecords([$otherUser]);
+ });
+});
diff --git a/tests/Feature/Http/WelcomePageTest.php b/tests/Feature/Http/WelcomePageTest.php
new file mode 100644
index 0000000..354587d
--- /dev/null
+++ b/tests/Feature/Http/WelcomePageTest.php
@@ -0,0 +1,7 @@
+get('/')
+ ->assertSuccessful()
+ ->assertViewIs('welcome');
+});
diff --git a/tests/Feature/Mail/InviteUserTest.php b/tests/Feature/Mail/InviteUserTest.php
new file mode 100644
index 0000000..d91ab60
--- /dev/null
+++ b/tests/Feature/Mail/InviteUserTest.php
@@ -0,0 +1,35 @@
+make([
+ 'invite_token' => Str::random(60),
+ ]);
+
+ expect((new InviteUser($user))->envelope()->subject)
+ ->toBe('You have been invited to join the team!');
+ });
+
+ it('includes a signed registration url with the invite token', function () {
+ $user = User::factory()->create([
+ 'invite_token' => 'test-invite-token',
+ ]);
+
+ $mailable = new InviteUser($user);
+ $content = $mailable->content();
+
+ expect($content->markdown)->toBe('mail.auth.invite-user')
+ ->and($content->with['acceptUrl'])
+ ->toContain('invite/register')
+ ->toContain('token=test-invite-token')
+ ->toContain('signature=');
+ });
+});
diff --git a/tests/Feature/Models/UserTest.php b/tests/Feature/Models/UserTest.php
new file mode 100644
index 0000000..4c4091b
--- /dev/null
+++ b/tests/Feature/Models/UserTest.php
@@ -0,0 +1,82 @@
+create();
+
+ expect($user->canAccessPanel(Filament::getPanel('dashboard')))->toBeTrue();
+ });
+
+ it('uses the user email as the app authentication holder name', function () {
+ $user = User::factory()->create([
+ 'email' => 'jane@example.com',
+ ]);
+
+ expect($user->getAppAuthenticationHolderName())->toBe('jane@example.com');
+ });
+
+ it('generates a ui-avatars url for the filament avatar', function () {
+ $user = User::factory()->create([
+ 'name' => 'Jane Doe',
+ ]);
+
+ expect($user->getFilamentAvatarUrl())
+ ->toStartWith('https://ui-avatars.com/api/')
+ ->toContain('name=');
+ });
+
+ it('allows super admins to impersonate other users', function () {
+ $superAdmin = createUserWithRole(RolesEnum::SUPER_ADMIN);
+
+ expect($superAdmin->canImpersonate())->toBeTrue();
+ });
+
+ it('allows users with impersonate permission to impersonate', function () {
+ $admin = createUserWithRole(RolesEnum::ADMIN);
+
+ expect($admin->canImpersonate())->toBeTrue();
+ });
+
+ it('does not allow editors without impersonate permission to impersonate', function () {
+ $editor = createUserWithRole(RolesEnum::EDITOR);
+
+ expect($editor->canImpersonate())->toBeFalse();
+ });
+
+ it('does not allow super admins to be impersonated', function () {
+ $superAdmin = createUserWithRole(RolesEnum::SUPER_ADMIN);
+
+ expect($superAdmin->canBeImpersonated())->toBeFalse();
+ });
+
+ it('allows non-super-admin users to be impersonated', function () {
+ $admin = createUserWithRole(RolesEnum::ADMIN);
+
+ expect($admin->canBeImpersonated())->toBeTrue();
+ });
+
+ it('persists app authentication secrets', function () {
+ $user = User::factory()->create();
+
+ $user->saveAppAuthenticationSecret('test-secret');
+
+ expect($user->fresh()->getAppAuthenticationSecret())->toBe('test-secret');
+ });
+
+ it('persists app authentication recovery codes', function () {
+ $user = User::factory()->create();
+ $codes = ['code-one', 'code-two'];
+
+ $user->saveAppAuthenticationRecoveryCodes($codes);
+
+ expect($user->fresh()->getAppAuthenticationRecoveryCodes())->toBe($codes);
+ });
+});
diff --git a/tests/Feature/Observers/UserObserverTest.php b/tests/Feature/Observers/UserObserverTest.php
new file mode 100644
index 0000000..fe736a0
--- /dev/null
+++ b/tests/Feature/Observers/UserObserverTest.php
@@ -0,0 +1,30 @@
+create();
+
+ expect($user->hasRole(RolesEnum::EDITOR))->toBeTrue();
+ });
+
+ it('does not assign the editor role when the user already has a role', function () {
+ $user = User::withoutEvents(function () {
+ $user = User::factory()->create();
+ $user->assignRole(RolesEnum::ADMIN);
+
+ return $user;
+ });
+
+ (new \App\Observers\UserObserver)->created($user);
+
+ expect($user->hasRole(RolesEnum::ADMIN))->toBeTrue()
+ ->and($user->hasRole(RolesEnum::EDITOR))->toBeFalse();
+ });
+});
diff --git a/tests/Pest.php b/tests/Pest.php
index 60f04a4..af3af63 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -1,29 +1,22 @@
extend(Tests\TestCase::class)
- // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
+ ->in('Unit');
+
+pest()->extend(Tests\TestCase::class)
+ ->use(RefreshDatabase::class)
->in('Feature');
/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
-|
-| When you're writing tests, you often need to check that values meet certain conditions. The
-| "expect()" function gives you access to a set of "expectations" methods that you can use
-| to assert different things. Of course, you may extend the Expectation API at any time.
-|
*/
expect()->extend('toBeOne', function () {
@@ -34,14 +27,29 @@
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
-|
-| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
-| project that you don't want to repeat in every file. Here you can also expose helpers as
-| global functions to help you to reduce the number of lines of code in your test files.
-|
*/
-function something()
+function seedRolesAndPermissions(): void
+{
+ test()->seed(RolesAndPermissionsSeeder::class);
+}
+
+function createUserWithRole(RolesEnum $role, array $attributes = []): User
{
- // ..
+ seedRolesAndPermissions();
+
+ $user = User::factory()->create($attributes);
+
+ $user->assignRole($role);
+
+ return $user;
+}
+
+function actingAsFilamentUser(User $user): User
+{
+ test()->actingAs($user);
+
+ Filament::setCurrentPanel(Filament::getPanel('dashboard'));
+
+ return $user;
}
diff --git a/tests/Unit/Enums/RolesEnumTest.php b/tests/Unit/Enums/RolesEnumTest.php
new file mode 100644
index 0000000..b908504
--- /dev/null
+++ b/tests/Unit/Enums/RolesEnumTest.php
@@ -0,0 +1,28 @@
+toHaveCount(3)
+ ->and(RolesEnum::SUPER_ADMIN->value)->toBe('super-admin')
+ ->and(RolesEnum::ADMIN->value)->toBe('admin')
+ ->and(RolesEnum::EDITOR->value)->toBe('editor');
+ });
+
+ it('returns human-readable labels', function (RolesEnum $role, string $label) {
+ expect($role->getLabel())->toBe($label);
+ })->with([
+ 'super admin' => [RolesEnum::SUPER_ADMIN, 'Super Admin'],
+ 'admin' => [RolesEnum::ADMIN, 'Admin'],
+ 'editor' => [RolesEnum::EDITOR, 'Editor'],
+ ]);
+
+ it('returns filament colors', function (RolesEnum $role, string $color) {
+ expect($role->getColor())->toBe($color);
+ })->with([
+ 'super admin' => [RolesEnum::SUPER_ADMIN, 'primary'],
+ 'admin' => [RolesEnum::ADMIN, 'success'],
+ 'editor' => [RolesEnum::EDITOR, 'warning'],
+ ]);
+});
diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php
deleted file mode 100644
index 44a4f33..0000000
--- a/tests/Unit/ExampleTest.php
+++ /dev/null
@@ -1,5 +0,0 @@
-toBeTrue();
-});