diff --git a/app/Livewire/Auth/ForgotPassword.php b/app/Livewire/Auth/ForgotPassword.php index 816c14c..4d71d9c 100644 --- a/app/Livewire/Auth/ForgotPassword.php +++ b/app/Livewire/Auth/ForgotPassword.php @@ -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 diff --git a/tests/Feature/Auth/ForgotPasswordTest.php b/tests/Feature/Auth/ForgotPasswordTest.php new file mode 100644 index 0000000..2d7d572 --- /dev/null +++ b/tests/Feature/Auth/ForgotPasswordTest.php @@ -0,0 +1,59 @@ +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(); +}); diff --git a/tests/Feature/Auth/LoginTest.php b/tests/Feature/Auth/LoginTest.php new file mode 100644 index 0000000..13f684f --- /dev/null +++ b/tests/Feature/Auth/LoginTest.php @@ -0,0 +1,97 @@ +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'); +}); diff --git a/tests/Feature/Auth/RegisterTest.php b/tests/Feature/Auth/RegisterTest.php new file mode 100644 index 0000000..54c2687 --- /dev/null +++ b/tests/Feature/Auth/RegisterTest.php @@ -0,0 +1,123 @@ +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, + ]); +}); diff --git a/tests/GUIDELINES.md b/tests/GUIDELINES.md new file mode 100644 index 0000000..486adc3 --- /dev/null +++ b/tests/GUIDELINES.md @@ -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. diff --git a/tests/Pest.php b/tests/Pest.php index 60f04a4..0055774 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -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');