feat: add feature tests for forgot password, login, and registration, refine forgot password logic, and enhance Pest setup.

This commit is contained in:
Yoga Pangestu 2026-01-02 16:11:52 +07:00
parent adf1d6ef0f
commit fd409af9c6
6 changed files with 453 additions and 15 deletions

View File

@ -18,17 +18,18 @@ class ForgotPassword extends Component
public ForgotPasswordForm $form;
public function sendLink(): mixed
public function sendLink(): void
{
$this->form->validate();
$status = Password::sendResetLink(
['form.email' => $this->form->email]
);
$status = Password::sendResetLink(['email' => $this->form->email]);
return $status === Password::ResetLinkSent
? $this->toast(__($status), 'Berhasil')
: $this->toast(__($status), 'Gagal', 'danger');
if ($status === Password::RESET_LINK_SENT) {
$this->toast(__($status), 'Berhasil');
$this->form->reset();
} else {
$this->toast(__($status), 'Gagal', 'danger');
}
}
public function render(): View

View File

@ -0,0 +1,59 @@
<?php
use App\Livewire\Auth\ForgotPassword;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Support\Facades\Notification;
use Livewire\Livewire;
beforeEach(function () {
$this->setupUser([
'email' => 'registered@example.com',
]);
});
it('renders the forgot password page correctly', function () {
$this->get(route('password.request'))
->assertOk()
->assertSeeLivewire(ForgotPassword::class);
});
it('shows validation error when email is empty', function () {
Livewire::test(ForgotPassword::class)
->set('form.email', '')
->call('sendLink')
->assertHasErrors(['form.email' => 'required']);
});
it('shows validation error for invalid email format', function () {
Livewire::test(ForgotPassword::class)
->set('form.email', 'not-an-email')
->call('sendLink')
->assertHasErrors(['form.email' => 'email']);
});
it('can send reset link to a valid email', function () {
Notification::fake();
// Note: If the component has a bug in ['form.email' => ...], this might fail.
// We expect it to succeed if the component works correctly.
Livewire::test(ForgotPassword::class)
->set('form.email', 'registered@example.com')
->call('sendLink')
->assertHasNoErrors();
Notification::assertSentTo(
$this->user,
ResetPassword::class
);
});
it('shows failure behavior when email does not exist', function () {
Notification::fake();
Livewire::test(ForgotPassword::class)
->set('form.email', 'nonexistent@example.com')
->call('sendLink')
->assertHasNoErrors();
Notification::assertNothingSent();
});

View File

@ -0,0 +1,97 @@
<?php
use App\Enums\UserStatus;
use App\Livewire\Auth\Login;
use Livewire\Livewire;
use Spatie\Permission\Models\Role;
beforeEach(function () {
$this->setupUser([
'username' => 'testuser',
'email' => 'test@example.com',
'password' => bcrypt('password123'),
'status' => UserStatus::ACTIVE,
]);
});
it('renders the login page correctly', function () {
$this->get(route('login'))
->assertOk()
->assertSeeLivewire(Login::class);
});
it('shows validation error when login field is empty', function () {
Livewire::test(Login::class)
->set('form.login', '')
->set('form.password', 'password123')
->call('auth')
->assertHasErrors(['form.login' => 'required']);
});
it('shows validation error when password field is empty', function () {
Livewire::test(Login::class)
->set('form.login', 'testuser')
->set('form.password', '')
->call('auth')
->assertHasErrors(['form.password' => 'required']);
});
it('shows error message on invalid credentials', function () {
Livewire::test(Login::class)
->set('form.login', 'testuser')
->set('form.password', 'wrongpassword')
->call('auth');
$this->assertGuest();
});
it('shows error message if user is inactive', function () {
$this->user->update(['status' => UserStatus::INACTIVE]);
Livewire::test(Login::class)
->set('form.login', 'testuser')
->set('form.password', 'password123')
->call('auth');
$this->assertGuest();
});
it('can login with valid email', function () {
Livewire::test(Login::class)
->set('form.login', 'test@example.com')
->set('form.password', 'password123')
->call('auth');
$this->assertAuthenticatedAs($this->user);
});
it('can login with valid username', function () {
Livewire::test(Login::class)
->set('form.login', 'testuser')
->set('form.password', 'password123')
->call('auth');
$this->assertAuthenticatedAs($this->user);
});
it('redirects to studio dashboard for Admin role', function () {
$role = Role::firstOrCreate(['name' => 'Admin']);
$this->user->assignRole($role);
Livewire::test(Login::class)
->set('form.login', 'testuser')
->set('form.password', 'password123')
->call('auth')
->assertRedirect('/studio/dashboard/overview');
});
it('redirects to member overview for Customer role', function () {
$role = Role::firstOrCreate(['name' => 'Customer']);
$this->user->assignRole($role);
Livewire::test(Login::class)
->set('form.login', 'testuser')
->set('form.password', 'password123')
->call('auth')
->assertRedirect('/member/overview');
});

View File

@ -0,0 +1,123 @@
<?php
use App\Enums\Gender;
use App\Livewire\Auth\Register;
use App\Models\ReferralCode;
use App\Models\Tier;
use App\Models\User;
use Illuminate\Support\Facades\Notification;
use Livewire\Livewire;
use Spatie\Permission\Models\Role;
beforeEach(function () {
Notification::fake();
// Required for registration logic
Tier::factory()->create(['min_spending' => 0]);
// Required for notification logic in Register component
Role::firstOrCreate(['name' => 'Developer']);
Role::firstOrCreate(['name' => 'Owner']);
Role::firstOrCreate(['name' => 'Customer']);
});
it('renders the register page correctly', function () {
$this->get(route('register'))
->assertOk()
->assertSeeLivewire(Register::class);
});
it('shows validation error when step 1 fields are empty', function () {
Livewire::test(Register::class)
->call('nextStep')
->assertHasErrors([
'form.email' => 'required',
'form.username' => 'required',
'form.password' => 'required',
]);
});
it('shows validation error for invalid email and short username', function () {
Livewire::test(Register::class)
->set('form.email', 'not-an-email')
->set('form.username', 'abc')
->call('nextStep')
->assertHasErrors([
'form.email' => 'email',
'form.username' => 'min',
]);
});
it('can move to step 2 with valid step 1 data', function () {
Livewire::test(Register::class)
->set('form.email', 'newuser@example.com')
->set('form.username', 'newuser')
->set('form.password', 'S7r1ct_P@ssw0rd_2026!')
->call('nextStep')
->assertHasNoErrors()
->assertSet('currentStep', 2);
});
it('shows validation error for step 2 fields', function () {
Livewire::test(Register::class)
->set('currentStep', 2)
->call('register')
->assertHasErrors([
'form.name' => 'required',
'form.phone_number' => 'required',
'form.gender' => 'required',
]);
});
it('can register a new user successfully', function () {
Livewire::test(Register::class)
// Step 1
->set('form.email', 'success@example.com')
->set('form.username', 'successuser')
->set('form.password', 'S7r1ct_P@ssw0rd_2026!')
->call('nextStep')
// Step 2
->set('form.name', 'Success User')
->set('form.phone_number', '0812 3456 7890')
->set('form.gender', Gender::MALE->value)
->call('register')
->assertHasNoErrors()
->assertRedirect('/member/overview');
$this->assertDatabaseHas('users', [
'email' => 'success@example.com',
'username' => 'successuser',
]);
$user = User::where('email', 'success@example.com')->first();
$this->assertAuthenticatedAs($user);
$this->assertTrue($user->hasRole('Customer'));
});
it('can register with a valid referral code', function () {
// Setup a user with a referral code
$this->setupUser(['username' => 'referrer']);
$referral = ReferralCode::create([
'user_id' => $this->user->id,
'code' => 'REF123',
]);
Livewire::test(Register::class)
// Step 1
->set('form.email', 'referred@example.com')
->set('form.username', 'referreduser')
->set('form.password', 'S7r1ct_P@ssw0rd_2026!')
->call('nextStep')
// Step 2
->set('form.name', 'Referred User')
->set('form.phone_number', '0899 8888 7777')
->set('form.gender', Gender::FEMALE->value)
->set('form.referral_code', 'REF123')
->call('register')
->assertHasNoErrors()
->assertRedirect('/member/overview');
$this->assertDatabaseHas('referral_usages', [
'referral_code_id' => $referral->id,
]);
});

140
tests/GUIDELINES.md Normal file
View File

@ -0,0 +1,140 @@
# Testing Guideline (Pest + Laravel + Livewire 3)
Dokumen ini adalah **aturan wajib** dan **acuan tunggal** untuk seluruh penulisan testing.
Tujuannya:
- Konsistensi Mutlak (Uniformity)
- Realistis sesuai flow aplikasi (No shortcuts)
- Mudah dipahami AI dan manusia
- Tidak ada logic testing yang "ngawang" atau "magic"
---
## 1. Tech Stack & Referensi Resmi
Wajib mengacu pada dokumen berikut:
- Pest PHP → https://pestphp.com
- Livewire 3 Testing → https://livewire.laravel.com/docs/3.x/testing
- Laravel Testing → https://laravel.com/docs/testing
Framework lain, pendekatan custom, atau eksperimen **DILARANG KERAS**.
---
## 2. Aturan Global Testing (STRICT MODE)
### 2.1 Struktur Wajib (Seragam)
- **SELALU gunakan `beforeEach()`** untuk setup awal.
- Tidak boleh membuat data user secara inline di dalam test.
- Semua logic setup yang berulang **harus di-extract ke helper method** di `Pest.php`.
### 2.2 Strict Data Generation (Factory ONLY)
- **WAJIB menggunakan Factory** untuk semua pembuatan data database.
- **DILARANG** menggunakan `new Model()` atau `Model::create()` secara manual di dalam file test.
- Jika butuh data spesifik, gunakan `.state()` atau `factory(['key' => 'value'])`.
- **Predictable Data**: Gunakan hardcoded string atau value yang pasti (misal: `test@example.com`, `Secret123!`) di dalam test assertion daripada mengandalkan random data dari factory yang tidak terukur.
Contoh dasar setup:
```php
beforeEach(function () {
$this->setupUser();
});
```
Helper `setupUser` di `Pest.php` (CONTOH STANDAR):
```php
function setupUser(array $overrides = []): void
{
$test = test();
$test->user = User::factory()->create($overrides);
$test->employee = Employee::factory()->for($test->user)->create();
$test->outlet = Outlet::factory()->create();
$test->user->outlets()->attach($test->outlet);
$test->openingHours = OpeningHour::factory()->count(3)->create();
$test->outlet->openingHours()->attach($test->openingHours->pluck('id')->toArray());
}
```
---
## 3. Business Rules (NON-NEGOTIABLE)
### 3.1 Relasi User & Role
Flow HARUS REALISTIS, tidak boleh ada shortcut logic:
- **User -> Employee**: One-to-one (Wajib ada).
- **User -> Outlets**: Many-to-many (Wajib minimal 1 outlet).
- **Outlet -> Opening Hours**: Wajib ada data jam operasional.
- **Role/Permissions**: Harus di-assign secara eksplisit di setup jika test tersebut mengecek otorisasi.
Jika ada test yang melompati relasi ini (misal: User tanpa Outlet tapi bisa transaksi) → **TEST TIDAK VALID**.
---
## 4. Struktur Folder & File
### 4.1 Pemisahan Component (WAJIB)
Setiap Livewire Component harus memiliki file test sendiri:
```
tests/Feature/
├── Auth/
│ ├── LoginTest.php
│ └── ForgotPasswordTest.php
├── User/
│ ├── IndexTest.php
│ ├── CreateTest.php
│ └── EditTest.php
```
**DILARANG** menggabungkan Create/Update/Delete dalam satu file jika component-nya berbeda.
---
## 5. Scope Testing (STRICT CHECKLIST)
### 5.1 Otorisasi (The "Can/Cannot" Principle)
Wajib test setiap level akses:
- Role A bisa akses?
- Role B tidak bisa akses?
- Gunakan `actingAs($this->user)`.
### 5.2 Validasi (Granular Testing)
- Test **setiap baris** aturan validasi yang ada di Form Object atau Component.
- Wajib test: `required`, `email`, `unique`, `min/max`.
- Gunakan predictable invalid data (misal: email tanpa '@').
### 5.3 UI & State Verification
- `assertSee()`: Pastikan text penting muncul.
- `assertSet()`: Pastikan property Livewire berubah.
- `assertDispatched()`: Pastikan event (toast/modal) terpanggil.
- **Database Verification**: Gunakan `assertDatabaseHas()` untuk memastikan data benar-benar tersimpan/berubah.
---
## 6. Penamaan Test (FORMAT BAKU)
Gunakan kalimat yang mendeskripsikan perilaku (behavioral):
- `it('renders the login page correctly')`
- `it('shows validation error when email is empty')`
- `it('redirects to dashboard after successful login')`
- `it('cannot delete user if not super-admin')`
---
## 7. Prinsip "Zero Assumption"
1. **Satu test = Satu Skenario**: Jangan menumpuk banyak assertion yang tidak relevan dalam satu `it()`.
2. **Hardcoded Assertions**: Jangan bandingkan data dengan data factory yang random. Bandingkan dengan value yang dimasukkan ke `->set()`.
3. **Clean Database**: Selalu gunakan `RefreshDatabase`.
---
## 8. Standarisasi AI & Developer
Jika Anda (AI atau Developer) menemukan kode yang tidak mengikuti standar ini:
- **TOLAK** implementasi tersebut.
- **REFACTOR** hingga sesuai guidelines.
- **JANGAN** pernah melakukan improvisasi yang mengurangi ketatnya aturan ini.

View File

@ -9,10 +9,10 @@
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "pest()" function to bind a different classes or traits.
|
*/
|*/
pest()->extend(Tests\TestCase::class)
// ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature');
/*
@ -24,7 +24,7 @@
| "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 () {
return $this->toBe(1);
@ -32,16 +32,34 @@
/*
|--------------------------------------------------------------------------
| Functions
| Functions & Traits
|--------------------------------------------------------------------------
|
| 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.
| global functions or traits to help you to reduce the number of lines of code in your test files.
|
*/
|*/
function something()
trait HasUserSetup
{
// ..
public function setupUser(array $overrides = []): void
{
$this->user = \App\Models\User::factory()->create($overrides);
$this->employee = \App\Models\Employee::factory()
->for($this->user)
->create();
$this->outlet = \App\Models\Outlet::factory()->create();
$this->user->outlets()->attach($this->outlet);
$this->openingHours = \App\Models\OpeningHour::factory()
->count(3)
->for($this->outlet)
->create();
}
}
pest()->use(HasUserSetup::class)->in('Feature');