Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Homestead.yaml
auth.json
npm-debug.log
yarn-error.log
/tests/Browser/Screenshots
.github_token
/.fleet
/.idea
Expand Down
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
"laravel/sail": "^1.26",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.0",
"pestphp/pest": "^3.7"
"pestphp/pest": "^4.0",
"pestphp/pest-plugin-browser": "^4.0",
"pestphp/pest-plugin-laravel": "^4.0"
},
"autoload": {
"psr-4": {
Expand Down
2,820 changes: 2,208 additions & 612 deletions composer.lock

Large diffs are not rendered by default.

412 changes: 244 additions & 168 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@inertiajs/react": "^2.0.14",
"@playwright/test": "^1.55.0",
"@tailwindcss/vite": "^4.1.11",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
Expand All @@ -31,6 +32,7 @@
"@intentui/icons": "^1.11.0",
"@types/node": "^22.15.34",
"motion": "^12.19.2",
"playwright": "^1.55.0",
"react": "^19.1.0",
"react-aria-components": "^1.10.1",
"react-dom": "^19.1.0",
Expand Down
3 changes: 3 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
colors="true"
>
<testsuites>
<testsuite name="Browser">
<directory>tests/Browser</directory>
</testsuite>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
Expand Down
49 changes: 49 additions & 0 deletions tests/Browser/LoginTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

use App\Models\User;

test('login page can be visited', function () {
$page = visit('/login');

$page->assertSee('Login')
->assertSee('Sign in with your email or continue with a connected account.');
});

test('user can login with valid credentials', function () {
User::factory()->create([
'email' => 'test@example.com',
'password' => 'password',
]);

$page = visit('/login');

$page->type('[name="email"]', 'test@example.com')
->type('[name="password"]', 'password')
->submit()
->wait(3)
->assertUrlIs(url('/dashboard'));
});

test('user cannot login with invalid credentials', function () {
User::factory()->create([
'email' => 'test@example.com',
'password' => 'password',
]);

$page = visit('/login');

$page->type('[name="email"]', 'test@example.com')
->type('[name="password"]', 'wrong-password')
->submit()
->wait(2)
->assertUrlIs(url('/login'));
});

test('login form shows validation errors for empty fields', function () {
$page = visit('/login');

$page->submit()
->wait(2)
->assertSee('Please fill out this field.')
->assertUrlIs(url('/login'));
});
107 changes: 107 additions & 0 deletions tests/Browser/RegisterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

use App\Models\User;

test('register page can be visited', function () {
$page = visit('/register');

$page->assertSee('Register')
->assertSee('Create an account to get started.');
});

test('user can register with valid data', function () {
$page = visit('/register');

$page->type('[name="name"]', 'John Doe')
->type('[name="email"]', 'john@example.com')
->type('[name="password"]', 'password123')
->type('[name="password_confirmation"]', 'password123')
->submit()
->wait(3)
->assertUrlIs(url('/dashboard'));

expect(User::where('email', 'john@example.com')->exists())->toBeTrue();
});

test('user cannot register with invalid email', function () {
$page = visit('/register');

$page->type('[name="name"]', 'John Doe')
->type('[name="email"]', 'invalid-email')
->type('[name="password"]', 'password123')
->type('[name="password_confirmation"]', 'password123')
->submit()
->wait(2)
->assertSee('email');
});

test('user cannot register with mismatched passwords', function () {
$page = visit('/register');

$page->type('[name="name"]', 'John Doe')
->type('[name="email"]', 'john@example.com')
->type('[name="password"]', 'password123')
->type('[name="password_confirmation"]', 'different-password')
->submit()
->wait(2)
->assertSee('password');
});

test('register form shows validation errors for empty fields', function () {
$page = visit('/register');

$page->submit()
->wait(2)
->assertSee('Please fill out this field.')
->assertUrlIs(url('/register'));
});

test('user cannot register with existing email', function () {
User::factory()->create([
'email' => 'existing@example.com',
]);

$page = visit('/register');

$page->type('[name="name"]', 'John Doe')
->type('[name="email"]', 'existing@example.com')
->type('[name="password"]', 'password123')
->type('[name="password_confirmation"]', 'password123')
->submit()
->wait(2)
->assertSee('email');
});

test('register form has link to login page', function () {
$page = visit('/register');

$page->assertSee('Already registered?')
->click('Already registered?')
->wait(2)
->assertUrlIs(url('/login'))
->assertSee('Login');
});

test('register form validates minimum password length', function () {
$page = visit('/register');

$page->type('[name="name"]', 'John Doe')
->type('[name="email"]', 'john@example.com')
->type('[name="password"]', '123')
->type('[name="password_confirmation"]', '123')
->submit()
->wait(2)
->assertSee('password');
});

test('register button shows loading state during submission', function () {
$page = visit('/register');

$page->type('[name="name"]', 'John Doe')
->type('[name="email"]', 'john@example.com')
->type('[name="password"]', 'password123')
->type('[name="password_confirmation"]', 'password123')
->submit()
->wait(3)
->assertUrlIs(url('/dashboard'));
});
34 changes: 30 additions & 4 deletions tests/Feature/Auth/AuthenticationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@

use App\Models\User;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

test('login screen can be rendered', function () {
$response = $this->get('/login');
$response = $this->get(route('login'));

$response->assertStatus(200);
});

test('users can authenticate using the login screen', function () {
$user = User::factory()->create();

$response = $this->post('/login', [
$response = $this->post(route('login'), [
'email' => $user->email,
'password' => 'password',
]);
Expand All @@ -23,7 +25,7 @@
test('users can not authenticate with invalid password', function () {
$user = User::factory()->create();

$this->post('/login', [
$this->post(route('login'), [
'email' => $user->email,
'password' => 'wrong-password',
]);
Expand All @@ -34,8 +36,32 @@
test('users can logout', function () {
$user = User::factory()->create();

$response = $this->actingAs($user)->post('/logout');
$response = $this->actingAs($user)->post(route('logout'));

$this->assertGuest();
$response->assertRedirect('/');
});

test('users are rate limited', function () {
$user = User::factory()->create();

for ($i = 0; $i < 5; $i++) {
$this->post(route('login'), [
'email' => $user->email,
'password' => 'wrong-password',
])->assertStatus(302)->assertSessionHasErrors([
'email' => 'These credentials do not match our records.',
]);
}

$response = $this->post(route('login'), [
'email' => $user->email,
'password' => 'wrong-password',
]);

$response->assertSessionHasErrors('email');

$errors = session('errors');

$this->assertStringContainsString('Too many login attempts', $errors->first('email'));
});
50 changes: 49 additions & 1 deletion tests/Feature/Auth/EmailVerificationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

test('email verification screen can be rendered', function () {
$user = User::factory()->unverified()->create();

$response = $this->actingAs($user)->get('/verify-email');
$response = $this->actingAs($user)->get(route('verification.notice'));

$response->assertStatus(200);
});
Expand Down Expand Up @@ -44,3 +46,49 @@

expect($user->fresh()->hasVerifiedEmail())->toBeFalse();
});

test('email is not verified with invalid user id', function () {
$user = User::factory()->create([
'email_verified_at' => null,
]);

$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => 123, 'hash' => sha1($user->email)]
);

$this->actingAs($user)->get($verificationUrl);

expect($user->fresh()->hasVerifiedEmail())->toBeFalse();
});

test('verified user is redirected to dashboard from verification prompt', function () {
$user = User::factory()->create([
'email_verified_at' => now(),
]);

$response = $this->actingAs($user)->get(route('verification.notice'));

$response->assertRedirect(route('dashboard', absolute: false));
});

test('already verified user visiting verification link is redirected without firing event again', function () {
$user = User::factory()->create([
'email_verified_at' => now(),
]);

Event::fake();

$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
);

$this->actingAs($user)->get($verificationUrl)
->assertRedirect(route('dashboard', absolute: false).'?verified=1');

expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
Event::assertNotDispatched(Verified::class);
});
8 changes: 5 additions & 3 deletions tests/Feature/Auth/PasswordConfirmationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@

use App\Models\User;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

test('confirm password screen can be rendered', function () {
$user = User::factory()->create();

$response = $this->actingAs($user)->get('/confirm-password');
$response = $this->actingAs($user)->get(route('password.confirm'));

$response->assertStatus(200);
});

test('password can be confirmed', function () {
$user = User::factory()->create();

$response = $this->actingAs($user)->post('/confirm-password', [
$response = $this->actingAs($user)->post(route('password.confirm'), [
'password' => 'password',
]);

Expand All @@ -24,7 +26,7 @@
test('password is not confirmed with invalid password', function () {
$user = User::factory()->create();

$response = $this->actingAs($user)->post('/confirm-password', [
$response = $this->actingAs($user)->post(route('password.confirm'), [
'password' => 'wrong-password',
]);

Expand Down
Loading