Initial commit

This commit is contained in:
2025-11-20 23:21:05 +08:00
commit 13bc6db529
378 changed files with 54527 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AuthenticationTest extends TestCase
{
use RefreshDatabase;
public function test_login_screen_can_be_rendered(): void
{
$response = $this->get('/login');
$response->assertStatus(200);
}
public function test_users_can_authenticate_using_the_login_screen(): void
{
$user = User::factory()->create();
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(RouteServiceProvider::HOME);
}
public function test_users_can_not_authenticate_with_invalid_password(): void
{
$user = User::factory()->create();
$this->post('/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$this->assertGuest();
}
public function test_users_can_logout(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/logout');
$this->assertGuest();
$response->assertRedirect('/');
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
class EmailVerificationTest extends TestCase
{
use RefreshDatabase;
public function test_email_verification_screen_can_be_rendered(): void
{
$user = User::factory()->create([
'email_verified_at' => null,
]);
$response = $this->actingAs($user)->get('/verify-email');
$response->assertStatus(200);
}
public function test_email_can_be_verified(): void
{
$user = User::factory()->create([
'email_verified_at' => null,
]);
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
);
$response = $this->actingAs($user)->get($verificationUrl);
Event::assertDispatched(Verified::class);
$this->assertTrue($user->fresh()->hasVerifiedEmail());
$response->assertRedirect(RouteServiceProvider::HOME.'?verified=1');
}
public function test_email_is_not_verified_with_invalid_hash(): void
{
$user = User::factory()->create([
'email_verified_at' => null,
]);
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1('wrong-email')]
);
$this->actingAs($user)->get($verificationUrl);
$this->assertFalse($user->fresh()->hasVerifiedEmail());
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PasswordConfirmationTest extends TestCase
{
use RefreshDatabase;
public function test_confirm_password_screen_can_be_rendered(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/confirm-password');
$response->assertStatus(200);
}
public function test_password_can_be_confirmed(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/confirm-password', [
'password' => 'password',
]);
$response->assertRedirect();
$response->assertSessionHasNoErrors();
}
public function test_password_is_not_confirmed_with_invalid_password(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/confirm-password', [
'password' => 'wrong-password',
]);
$response->assertSessionHasErrors();
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class PasswordResetTest extends TestCase
{
use RefreshDatabase;
public function test_reset_password_link_screen_can_be_rendered(): void
{
$response = $this->get('/forgot-password');
$response->assertStatus(200);
}
public function test_reset_password_link_can_be_requested(): void
{
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class);
}
public function test_reset_password_screen_can_be_rendered(): void
{
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
$response = $this->get('/reset-password/'.$notification->token);
$response->assertStatus(200);
return true;
});
}
public function test_password_can_be_reset_with_valid_token(): void
{
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
$response = $this->post('/reset-password', [
'token' => $notification->token,
'email' => $user->email,
'password' => 'password',
'password_confirmation' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('login'));
return true;
});
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class PasswordUpdateTest extends TestCase
{
use RefreshDatabase;
public function test_password_can_be_updated(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from('/profile')
->put('/password', [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/profile');
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
}
public function test_correct_password_must_be_provided_to_update_password(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from('/profile')
->put('/password', [
'current_password' => 'wrong-password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasErrorsIn('updatePassword', 'current_password')
->assertRedirect('/profile');
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Tests\Feature\Auth;
use App\Providers\RouteServiceProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class RegistrationTest extends TestCase
{
use RefreshDatabase;
public function test_registration_screen_can_be_rendered(): void
{
$response = $this->get('/register');
$response->assertStatus(200);
}
public function test_new_users_can_register(): void
{
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(RouteServiceProvider::HOME);
}
}

View File

@@ -0,0 +1,245 @@
<?php
namespace Tests\Feature;
use App\Models\Member;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AuthorizationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->artisan('db:seed', ['--class' => 'RoleSeeder']);
$this->artisan('db:seed', ['--class' => 'PaymentVerificationRolesSeeder']);
}
public function test_admin_middleware_allows_admin_role(): void
{
$admin = User::factory()->create();
$admin->assignRole('admin');
$response = $this->actingAs($admin)->get(route('admin.dashboard'));
$response->assertStatus(200);
}
public function test_admin_middleware_allows_is_admin_flag(): void
{
$admin = User::factory()->create(['is_admin' => true]);
$response = $this->actingAs($admin)->get(route('admin.dashboard'));
$response->assertStatus(200);
}
public function test_admin_middleware_blocks_non_admin_users(): void
{
$user = User::factory()->create(['is_admin' => false]);
$response = $this->actingAs($user)->get(route('admin.dashboard'));
$response->assertStatus(403);
}
public function test_paid_membership_middleware_allows_active_members(): void
{
$user = User::factory()->create();
$member = Member::factory()->create([
'user_id' => $user->id,
'membership_status' => Member::STATUS_ACTIVE,
'membership_started_at' => now()->subMonth(),
'membership_expires_at' => now()->addYear(),
]);
// Would need a route protected by CheckPaidMembership middleware
// For now we test the model method
$this->assertTrue($member->hasPaidMembership());
}
public function test_paid_membership_middleware_blocks_pending_members(): void
{
$user = User::factory()->create();
$member = Member::factory()->create([
'user_id' => $user->id,
'membership_status' => Member::STATUS_PENDING,
]);
$this->assertFalse($member->hasPaidMembership());
}
public function test_paid_membership_middleware_blocks_expired_members(): void
{
$user = User::factory()->create();
$member = Member::factory()->create([
'user_id' => $user->id,
'membership_status' => Member::STATUS_ACTIVE,
'membership_started_at' => now()->subYear()->subMonth(),
'membership_expires_at' => now()->subMonth(),
]);
$this->assertFalse($member->hasPaidMembership());
}
public function test_cashier_permission_enforced(): void
{
$cashier = User::factory()->create(['is_admin' => true]);
$cashier->givePermissionTo('verify_payments_cashier');
$this->assertTrue($cashier->can('verify_payments_cashier'));
$this->assertFalse($cashier->can('verify_payments_accountant'));
$this->assertFalse($cashier->can('verify_payments_chair'));
}
public function test_accountant_permission_enforced(): void
{
$accountant = User::factory()->create(['is_admin' => true]);
$accountant->givePermissionTo('verify_payments_accountant');
$this->assertTrue($accountant->can('verify_payments_accountant'));
$this->assertFalse($accountant->can('verify_payments_cashier'));
$this->assertFalse($accountant->can('verify_payments_chair'));
}
public function test_chair_permission_enforced(): void
{
$chair = User::factory()->create(['is_admin' => true]);
$chair->givePermissionTo('verify_payments_chair');
$this->assertTrue($chair->can('verify_payments_chair'));
$this->assertFalse($cashier->can('verify_payments_cashier'));
$this->assertFalse($accountant->can('verify_payments_accountant'));
}
public function test_membership_manager_permission_enforced(): void
{
$manager = User::factory()->create(['is_admin' => true]);
$manager->givePermissionTo('activate_memberships');
$this->assertTrue($manager->can('activate_memberships'));
}
public function test_unauthorized_users_get_403(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('admin.members.index'));
$response->assertStatus(403);
}
public function test_role_assignment_works(): void
{
$user = User::factory()->create(['is_admin' => true]);
$user->assignRole('payment_cashier');
$this->assertTrue($user->hasRole('payment_cashier'));
$this->assertTrue($user->can('verify_payments_cashier'));
$this->assertTrue($user->can('view_payment_verifications'));
}
public function test_permission_inheritance_works(): void
{
$user = User::factory()->create(['is_admin' => true]);
$user->assignRole('payment_cashier');
// payment_cashier role should have these permissions
$this->assertTrue($user->can('verify_payments_cashier'));
$this->assertTrue($user->can('view_payment_verifications'));
}
public function test_admin_role_has_all_permissions(): void
{
$admin = User::factory()->create();
$admin->assignRole('admin');
$this->assertTrue($admin->can('verify_payments_cashier'));
$this->assertTrue($admin->can('verify_payments_accountant'));
$this->assertTrue($admin->can('verify_payments_chair'));
$this->assertTrue($admin->can('activate_memberships'));
$this->assertTrue($admin->can('view_payment_verifications'));
}
public function test_members_cannot_access_admin_routes(): void
{
$user = User::factory()->create();
Member::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->get(route('admin.members.index'));
$response->assertStatus(403);
}
public function test_suspended_members_cannot_access_paid_resources(): void
{
$user = User::factory()->create();
$member = Member::factory()->create([
'user_id' => $user->id,
'membership_status' => Member::STATUS_SUSPENDED,
]);
$this->assertFalse($member->hasPaidMembership());
}
public function test_guest_users_redirected_to_login(): void
{
$response = $this->get(route('admin.dashboard'));
$response->assertRedirect(route('login'));
}
public function test_guest_users_cannot_access_member_routes(): void
{
$response = $this->get(route('member.dashboard'));
$response->assertRedirect(route('login'));
}
public function test_payment_cashier_role_has_correct_permissions(): void
{
$user = User::factory()->create(['is_admin' => true]);
$user->assignRole('payment_cashier');
$this->assertTrue($user->hasRole('payment_cashier'));
$this->assertTrue($user->can('verify_payments_cashier'));
$this->assertTrue($user->can('view_payment_verifications'));
$this->assertFalse($user->can('verify_payments_accountant'));
}
public function test_payment_accountant_role_has_correct_permissions(): void
{
$user = User::factory()->create(['is_admin' => true]);
$user->assignRole('payment_accountant');
$this->assertTrue($user->hasRole('payment_accountant'));
$this->assertTrue($user->can('verify_payments_accountant'));
$this->assertTrue($user->can('view_payment_verifications'));
$this->assertFalse($user->can('verify_payments_cashier'));
}
public function test_payment_chair_role_has_correct_permissions(): void
{
$user = User::factory()->create(['is_admin' => true]);
$user->assignRole('payment_chair');
$this->assertTrue($user->hasRole('payment_chair'));
$this->assertTrue($user->can('verify_payments_chair'));
$this->assertTrue($user->can('view_payment_verifications'));
$this->assertFalse($user->can('activate_memberships'));
}
public function test_membership_manager_role_has_correct_permissions(): void
{
$user = User::factory()->create(['is_admin' => true]);
$user->assignRole('membership_manager');
$this->assertTrue($user->hasRole('membership_manager'));
$this->assertTrue($user->can('activate_memberships'));
$this->assertTrue($user->can('view_payment_verifications'));
$this->assertFalse($user->can('verify_payments_cashier'));
}
}

View File

@@ -0,0 +1,368 @@
<?php
namespace Tests\Feature;
use App\Models\BankReconciliation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
/**
* Bank Reconciliation Workflow Feature Tests
*
* Tests bank reconciliation creation, review, and approval
*/
class BankReconciliationWorkflowTest extends TestCase
{
use RefreshDatabase, WithFaker;
protected User $cashier;
protected User $accountant;
protected User $manager;
protected function setUp(): void
{
parent::setUp();
Role::create(['name' => 'finance_cashier']);
Role::create(['name' => 'finance_accountant']);
Role::create(['name' => 'finance_chair']);
$this->cashier = User::factory()->create(['email' => 'cashier@test.com']);
$this->accountant = User::factory()->create(['email' => 'accountant@test.com']);
$this->manager = User::factory()->create(['email' => 'manager@test.com']);
$this->cashier->assignRole('finance_cashier');
$this->accountant->assignRole('finance_accountant');
$this->manager->assignRole('finance_chair');
$this->cashier->givePermissionTo(['prepare_bank_reconciliation', 'view_bank_reconciliations']);
$this->accountant->givePermissionTo(['review_bank_reconciliation', 'view_bank_reconciliations']);
$this->manager->givePermissionTo(['approve_bank_reconciliation', 'view_bank_reconciliations']);
}
/** @test */
public function cashier_can_create_bank_reconciliation()
{
Storage::fake('local');
$this->actingAs($this->cashier);
$statement = UploadedFile::fake()->create('statement.pdf', 100);
$response = $this->post(route('admin.bank-reconciliations.store'), [
'reconciliation_month' => now()->format('Y-m'),
'bank_statement_date' => now()->format('Y-m-d'),
'bank_statement_balance' => 100000,
'system_book_balance' => 95000,
'bank_statement_file' => $statement,
'outstanding_checks' => [
['check_number' => 'CHK001', 'amount' => 3000, 'description' => 'Vendor payment'],
['check_number' => 'CHK002', 'amount' => 2000, 'description' => 'Service fee'],
],
'deposits_in_transit' => [
['date' => now()->format('Y-m-d'), 'amount' => 5000, 'description' => 'Member dues'],
],
'bank_charges' => [
['amount' => 500, 'description' => 'Monthly service charge'],
],
'notes' => 'Monthly reconciliation',
]);
$response->assertRedirect();
$this->assertDatabaseHas('bank_reconciliations', [
'bank_statement_balance' => 100000,
'system_book_balance' => 95000,
'prepared_by_cashier_id' => $this->cashier->id,
'reconciliation_status' => 'pending',
]);
}
/** @test */
public function reconciliation_calculates_adjusted_balance_correctly()
{
$reconciliation = BankReconciliation::create([
'reconciliation_month' => now()->startOfMonth(),
'bank_statement_date' => now(),
'bank_statement_balance' => 100000,
'system_book_balance' => 95000,
'outstanding_checks' => [
['check_number' => 'CHK001', 'amount' => 3000, 'description' => 'Test'],
['check_number' => 'CHK002', 'amount' => 2000, 'description' => 'Test'],
],
'deposits_in_transit' => [
['date' => now()->format('Y-m-d'), 'amount' => 5000, 'description' => 'Test'],
],
'bank_charges' => [
['amount' => 500, 'description' => 'Test'],
],
'prepared_by_cashier_id' => $this->cashier->id,
'prepared_at' => now(),
'reconciliation_status' => 'pending',
]);
// Adjusted balance = 95000 + 5000 - 3000 - 2000 - 500 = 94500
$adjustedBalance = $reconciliation->calculateAdjustedBalance();
$this->assertEquals(94500, $adjustedBalance);
}
/** @test */
public function discrepancy_is_calculated_correctly()
{
$reconciliation = BankReconciliation::create([
'reconciliation_month' => now()->startOfMonth(),
'bank_statement_date' => now(),
'bank_statement_balance' => 100000,
'system_book_balance' => 95000,
'outstanding_checks' => [
['check_number' => 'CHK001', 'amount' => 3000, 'description' => 'Test'],
],
'deposits_in_transit' => [
['date' => now()->format('Y-m-d'), 'amount' => 5000, 'description' => 'Test'],
],
'bank_charges' => [
['amount' => 500, 'description' => 'Test'],
],
'prepared_by_cashier_id' => $this->cashier->id,
'prepared_at' => now(),
'reconciliation_status' => 'pending',
]);
// Adjusted balance = 95000 + 5000 - 3000 - 500 = 96500
// Discrepancy = |100000 - 96500| = 3500
$discrepancy = $reconciliation->calculateDiscrepancy();
$this->assertEquals(3500, $discrepancy);
$reconciliation->discrepancy_amount = $discrepancy;
$reconciliation->save();
$this->assertTrue($reconciliation->hasDiscrepancy());
}
/** @test */
public function accountant_can_review_reconciliation()
{
$reconciliation = BankReconciliation::create([
'reconciliation_month' => now()->startOfMonth(),
'bank_statement_date' => now(),
'bank_statement_balance' => 100000,
'system_book_balance' => 100000,
'outstanding_checks' => [],
'deposits_in_transit' => [],
'bank_charges' => [],
'prepared_by_cashier_id' => $this->cashier->id,
'prepared_at' => now(),
'reconciliation_status' => 'pending',
'discrepancy_amount' => 0,
]);
$this->actingAs($this->accountant);
$response = $this->post(route('admin.bank-reconciliations.review', $reconciliation), [
'review_notes' => 'Reviewed and looks correct',
]);
$response->assertRedirect();
$reconciliation->refresh();
$this->assertNotNull($reconciliation->reviewed_at);
$this->assertEquals($this->accountant->id, $reconciliation->reviewed_by_accountant_id);
}
/** @test */
public function manager_can_approve_reviewed_reconciliation()
{
$reconciliation = BankReconciliation::create([
'reconciliation_month' => now()->startOfMonth(),
'bank_statement_date' => now(),
'bank_statement_balance' => 100000,
'system_book_balance' => 100000,
'outstanding_checks' => [],
'deposits_in_transit' => [],
'bank_charges' => [],
'prepared_by_cashier_id' => $this->cashier->id,
'prepared_at' => now(),
'reviewed_by_accountant_id' => $this->accountant->id,
'reviewed_at' => now(),
'reconciliation_status' => 'pending',
'discrepancy_amount' => 0,
]);
$this->actingAs($this->manager);
$response = $this->post(route('admin.bank-reconciliations.approve', $reconciliation), [
'approval_notes' => 'Approved',
]);
$response->assertRedirect();
$reconciliation->refresh();
$this->assertNotNull($reconciliation->approved_at);
$this->assertEquals($this->manager->id, $reconciliation->approved_by_manager_id);
$this->assertEquals('completed', $reconciliation->reconciliation_status);
}
/** @test */
public function cannot_approve_unreviewed_reconciliation()
{
$reconciliation = BankReconciliation::create([
'reconciliation_month' => now()->startOfMonth(),
'bank_statement_date' => now(),
'bank_statement_balance' => 100000,
'system_book_balance' => 100000,
'outstanding_checks' => [],
'deposits_in_transit' => [],
'bank_charges' => [],
'prepared_by_cashier_id' => $this->cashier->id,
'prepared_at' => now(),
'reconciliation_status' => 'pending',
'discrepancy_amount' => 0,
]);
$this->assertFalse($reconciliation->canBeApproved());
}
/** @test */
public function reconciliation_with_large_discrepancy_is_flagged()
{
$reconciliation = BankReconciliation::create([
'reconciliation_month' => now()->startOfMonth(),
'bank_statement_date' => now(),
'bank_statement_balance' => 100000,
'system_book_balance' => 90000, // Large discrepancy
'outstanding_checks' => [],
'deposits_in_transit' => [],
'bank_charges' => [],
'prepared_by_cashier_id' => $this->cashier->id,
'prepared_at' => now(),
'reconciliation_status' => 'pending',
'discrepancy_amount' => 10000,
]);
$this->assertTrue($reconciliation->hasDiscrepancy());
$this->assertTrue($reconciliation->hasUnresolvedDiscrepancy());
}
/** @test */
public function outstanding_items_summary_is_calculated_correctly()
{
$reconciliation = BankReconciliation::create([
'reconciliation_month' => now()->startOfMonth(),
'bank_statement_date' => now(),
'bank_statement_balance' => 100000,
'system_book_balance' => 95000,
'outstanding_checks' => [
['check_number' => 'CHK001', 'amount' => 3000, 'description' => 'Test 1'],
['check_number' => 'CHK002', 'amount' => 2000, 'description' => 'Test 2'],
],
'deposits_in_transit' => [
['date' => now()->format('Y-m-d'), 'amount' => 5000, 'description' => 'Test 1'],
['date' => now()->format('Y-m-d'), 'amount' => 3000, 'description' => 'Test 2'],
],
'bank_charges' => [
['amount' => 500, 'description' => 'Test 1'],
['amount' => 200, 'description' => 'Test 2'],
],
'prepared_by_cashier_id' => $this->cashier->id,
'prepared_at' => now(),
'reconciliation_status' => 'pending',
]);
$summary = $reconciliation->getOutstandingItemsSummary();
$this->assertEquals(5000, $summary['total_outstanding_checks']);
$this->assertEquals(2, $summary['outstanding_checks_count']);
$this->assertEquals(8000, $summary['total_deposits_in_transit']);
$this->assertEquals(2, $summary['deposits_in_transit_count']);
$this->assertEquals(700, $summary['total_bank_charges']);
$this->assertEquals(2, $summary['bank_charges_count']);
}
/** @test */
public function completed_reconciliation_can_generate_pdf()
{
$reconciliation = BankReconciliation::create([
'reconciliation_month' => now()->startOfMonth(),
'bank_statement_date' => now(),
'bank_statement_balance' => 100000,
'system_book_balance' => 100000,
'outstanding_checks' => [],
'deposits_in_transit' => [],
'bank_charges' => [],
'prepared_by_cashier_id' => $this->cashier->id,
'prepared_at' => now(),
'reviewed_by_accountant_id' => $this->accountant->id,
'reviewed_at' => now(),
'approved_by_manager_id' => $this->manager->id,
'approved_at' => now(),
'reconciliation_status' => 'completed',
'discrepancy_amount' => 0,
]);
$this->assertTrue($reconciliation->isCompleted());
$this->actingAs($this->cashier);
$response = $this->get(route('admin.bank-reconciliations.pdf', $reconciliation));
$response->assertStatus(200);
}
/** @test */
public function reconciliation_status_text_is_correct()
{
$pending = new BankReconciliation(['reconciliation_status' => 'pending']);
$this->assertEquals('待覆核', $pending->getStatusText());
$completed = new BankReconciliation(['reconciliation_status' => 'completed']);
$this->assertEquals('已完成', $completed->getStatusText());
$discrepancy = new BankReconciliation(['reconciliation_status' => 'discrepancy']);
$this->assertEquals('有差異', $discrepancy->getStatusText());
}
/** @test */
public function reconciliation_workflow_is_sequential()
{
$reconciliation = BankReconciliation::create([
'reconciliation_month' => now()->startOfMonth(),
'bank_statement_date' => now(),
'bank_statement_balance' => 100000,
'system_book_balance' => 100000,
'outstanding_checks' => [],
'deposits_in_transit' => [],
'bank_charges' => [],
'prepared_by_cashier_id' => $this->cashier->id,
'prepared_at' => now(),
'reconciliation_status' => 'pending',
'discrepancy_amount' => 0,
]);
// Can be reviewed initially
$this->assertTrue($reconciliation->canBeReviewed());
$this->assertFalse($reconciliation->canBeApproved());
// After review
$reconciliation->reviewed_by_accountant_id = $this->accountant->id;
$reconciliation->reviewed_at = now();
$reconciliation->save();
$this->assertFalse($reconciliation->canBeReviewed());
$this->assertTrue($reconciliation->canBeApproved());
// After approval
$reconciliation->approved_by_manager_id = $this->manager->id;
$reconciliation->approved_at = now();
$reconciliation->reconciliation_status = 'completed';
$reconciliation->save();
$this->assertFalse($reconciliation->canBeReviewed());
$this->assertFalse($reconciliation->canBeApproved());
$this->assertTrue($reconciliation->isCompleted());
}
}

View File

@@ -0,0 +1,308 @@
<?php
namespace Tests\Feature;
use App\Models\CashierLedgerEntry;
use App\Models\FinanceDocument;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
/**
* Cashier Ledger Workflow Feature Tests
*
* Tests cashier ledger entry creation and balance tracking
*/
class CashierLedgerWorkflowTest extends TestCase
{
use RefreshDatabase, WithFaker;
protected User $cashier;
protected function setUp(): void
{
parent::setUp();
Role::create(['name' => 'finance_cashier']);
$this->cashier = User::factory()->create(['email' => 'cashier@test.com']);
$this->cashier->assignRole('finance_cashier');
$this->cashier->givePermissionTo(['record_cashier_entry', 'view_cashier_ledger']);
}
/** @test */
public function cashier_can_create_receipt_entry()
{
$this->actingAs($this->cashier);
$response = $this->post(route('admin.cashier-ledger.store'), [
'entry_type' => 'receipt',
'entry_date' => now()->format('Y-m-d'),
'amount' => 5000,
'payment_method' => 'bank_transfer',
'bank_account' => 'Test Bank 1234567890',
'receipt_number' => 'RCP001',
'notes' => 'Test receipt entry',
]);
$response->assertRedirect();
$this->assertDatabaseHas('cashier_ledger_entries', [
'entry_type' => 'receipt',
'amount' => 5000,
'bank_account' => 'Test Bank 1234567890',
'recorded_by_cashier_id' => $this->cashier->id,
]);
}
/** @test */
public function receipt_entry_increases_balance()
{
// Create initial entry
$entry1 = CashierLedgerEntry::create([
'entry_type' => 'receipt',
'entry_date' => now(),
'amount' => 10000,
'payment_method' => 'cash',
'bank_account' => 'Test Account',
'balance_before' => 0,
'balance_after' => 10000,
'recorded_by_cashier_id' => $this->cashier->id,
'recorded_at' => now(),
]);
$this->assertEquals(10000, $entry1->balance_after);
// Create second receipt entry
$entry2 = CashierLedgerEntry::create([
'entry_type' => 'receipt',
'entry_date' => now(),
'amount' => 5000,
'payment_method' => 'cash',
'bank_account' => 'Test Account',
'balance_before' => 10000,
'balance_after' => 15000,
'recorded_by_cashier_id' => $this->cashier->id,
'recorded_at' => now(),
]);
$this->assertEquals(15000, $entry2->balance_after);
$this->assertEquals(15000, CashierLedgerEntry::getLatestBalance('Test Account'));
}
/** @test */
public function payment_entry_decreases_balance()
{
// Create initial balance
CashierLedgerEntry::create([
'entry_type' => 'receipt',
'entry_date' => now(),
'amount' => 20000,
'payment_method' => 'cash',
'bank_account' => 'Test Account',
'balance_before' => 0,
'balance_after' => 20000,
'recorded_by_cashier_id' => $this->cashier->id,
'recorded_at' => now(),
]);
// Create payment entry
$paymentEntry = CashierLedgerEntry::create([
'entry_type' => 'payment',
'entry_date' => now(),
'amount' => 8000,
'payment_method' => 'cash',
'bank_account' => 'Test Account',
'balance_before' => 20000,
'balance_after' => 12000,
'recorded_by_cashier_id' => $this->cashier->id,
'recorded_at' => now(),
]);
$this->assertEquals(12000, $paymentEntry->balance_after);
$this->assertEquals(12000, CashierLedgerEntry::getLatestBalance('Test Account'));
}
/** @test */
public function balance_calculation_is_correct()
{
$entry = new CashierLedgerEntry([
'entry_type' => 'receipt',
'amount' => 5000,
]);
$newBalance = $entry->calculateBalanceAfter(10000);
$this->assertEquals(15000, $newBalance);
$entry->entry_type = 'payment';
$newBalance = $entry->calculateBalanceAfter(10000);
$this->assertEquals(5000, $newBalance);
}
/** @test */
public function separate_balances_are_maintained_for_different_accounts()
{
// Account 1
CashierLedgerEntry::create([
'entry_type' => 'receipt',
'entry_date' => now(),
'amount' => 10000,
'payment_method' => 'bank_transfer',
'bank_account' => 'Bank A - 1111',
'balance_before' => 0,
'balance_after' => 10000,
'recorded_by_cashier_id' => $this->cashier->id,
'recorded_at' => now(),
]);
// Account 2
CashierLedgerEntry::create([
'entry_type' => 'receipt',
'entry_date' => now(),
'amount' => 5000,
'payment_method' => 'bank_transfer',
'bank_account' => 'Bank B - 2222',
'balance_before' => 0,
'balance_after' => 5000,
'recorded_by_cashier_id' => $this->cashier->id,
'recorded_at' => now(),
]);
$this->assertEquals(10000, CashierLedgerEntry::getLatestBalance('Bank A - 1111'));
$this->assertEquals(5000, CashierLedgerEntry::getLatestBalance('Bank B - 2222'));
}
/** @test */
public function ledger_entry_can_be_linked_to_finance_document()
{
$financeDoc = FinanceDocument::factory()->create([
'amount' => 3000,
'status' => 'approved_accountant',
]);
$entry = CashierLedgerEntry::create([
'finance_document_id' => $financeDoc->id,
'entry_type' => 'payment',
'entry_date' => now(),
'amount' => 3000,
'payment_method' => 'cash',
'bank_account' => 'Test Account',
'balance_before' => 10000,
'balance_after' => 7000,
'recorded_by_cashier_id' => $this->cashier->id,
'recorded_at' => now(),
]);
$this->assertEquals($financeDoc->id, $entry->finance_document_id);
$this->assertInstanceOf(FinanceDocument::class, $entry->financeDocument);
}
/** @test */
public function entry_type_helper_methods_work_correctly()
{
$receiptEntry = new CashierLedgerEntry(['entry_type' => 'receipt']);
$this->assertTrue($receiptEntry->isReceipt());
$this->assertFalse($receiptEntry->isPayment());
$paymentEntry = new CashierLedgerEntry(['entry_type' => 'payment']);
$this->assertTrue($paymentEntry->isPayment());
$this->assertFalse($paymentEntry->isReceipt());
}
/** @test */
public function payment_method_text_is_correctly_displayed()
{
$entry1 = new CashierLedgerEntry(['payment_method' => 'cash']);
$this->assertEquals('現金', $entry1->getPaymentMethodText());
$entry2 = new CashierLedgerEntry(['payment_method' => 'bank_transfer']);
$this->assertEquals('銀行轉帳', $entry2->getPaymentMethodText());
$entry3 = new CashierLedgerEntry(['payment_method' => 'check']);
$this->assertEquals('支票', $entry3->getPaymentMethodText());
}
/** @test */
public function cashier_can_view_balance_report()
{
// Create multiple entries
CashierLedgerEntry::create([
'entry_type' => 'receipt',
'entry_date' => now(),
'amount' => 100000,
'payment_method' => 'bank_transfer',
'bank_account' => 'Main Account',
'balance_before' => 0,
'balance_after' => 100000,
'recorded_by_cashier_id' => $this->cashier->id,
'recorded_at' => now(),
]);
CashierLedgerEntry::create([
'entry_type' => 'payment',
'entry_date' => now(),
'amount' => 30000,
'payment_method' => 'bank_transfer',
'bank_account' => 'Main Account',
'balance_before' => 100000,
'balance_after' => 70000,
'recorded_by_cashier_id' => $this->cashier->id,
'recorded_at' => now(),
]);
$this->actingAs($this->cashier);
$response = $this->get(route('admin.cashier-ledger.balance-report'));
$response->assertStatus(200);
$response->assertViewHas('accounts');
$response->assertViewHas('monthlySummary');
}
/** @test */
public function zero_balance_is_returned_for_new_account()
{
$balance = CashierLedgerEntry::getLatestBalance('New Account');
$this->assertEquals(0, $balance);
}
/** @test */
public function ledger_entries_can_be_filtered_by_date_range()
{
$this->actingAs($this->cashier);
// Create entries with different dates
CashierLedgerEntry::create([
'entry_type' => 'receipt',
'entry_date' => now()->subDays(10),
'amount' => 1000,
'payment_method' => 'cash',
'bank_account' => 'Test',
'balance_before' => 0,
'balance_after' => 1000,
'recorded_by_cashier_id' => $this->cashier->id,
'recorded_at' => now()->subDays(10),
]);
CashierLedgerEntry::create([
'entry_type' => 'receipt',
'entry_date' => now(),
'amount' => 2000,
'payment_method' => 'cash',
'bank_account' => 'Test',
'balance_before' => 1000,
'balance_after' => 3000,
'recorded_by_cashier_id' => $this->cashier->id,
'recorded_at' => now(),
]);
$response = $this->get(route('admin.cashier-ledger.index', [
'date_from' => now()->subDays(5)->format('Y-m-d'),
'date_to' => now()->format('Y-m-d'),
]));
$response->assertStatus(200);
}
}

298
tests/Feature/EmailTest.php Normal file
View File

@@ -0,0 +1,298 @@
<?php
namespace Tests\Feature;
use App\Mail\MemberRegistrationWelcomeMail;
use App\Mail\MembershipActivatedMail;
use App\Mail\PaymentApprovedByAccountantMail;
use App\Mail\PaymentApprovedByCashierMail;
use App\Mail\PaymentFullyApprovedMail;
use App\Mail\PaymentRejectedMail;
use App\Mail\PaymentSubmittedMail;
use App\Models\Member;
use App\Models\MembershipPayment;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class EmailTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->artisan('db:seed', ['--class' => 'RoleSeeder']);
}
public function test_member_registration_welcome_mail_content(): void
{
$member = Member::factory()->create([
'full_name' => 'John Doe',
'email' => 'john@example.com',
]);
$mailable = new MemberRegistrationWelcomeMail($member);
$mailable->assertSeeInHtml('John Doe');
$mailable->assertSeeInHtml('Welcome');
}
public function test_payment_submitted_mail_member_variant(): void
{
$member = Member::factory()->create(['email' => 'member@example.com']);
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'amount' => 1000,
]);
$mailable = new PaymentSubmittedMail($payment, 'member');
$mailable->assertSeeInHtml('1,000');
$mailable->assertSeeInHtml('submitted');
}
public function test_payment_submitted_mail_cashier_variant(): void
{
$member = Member::factory()->create();
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'amount' => 1000,
]);
$mailable = new PaymentSubmittedMail($payment, 'cashier');
$mailable->assertSeeInHtml('review');
$mailable->assertSeeInHtml('1,000');
}
public function test_payment_approved_by_cashier_mail(): void
{
$member = Member::factory()->create();
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'status' => MembershipPayment::STATUS_APPROVED_CASHIER,
'amount' => 1000,
]);
$mailable = new PaymentApprovedByCashierMail($payment);
$mailable->assertSeeInHtml('Cashier');
$mailable->assertSeeInHtml('approved');
}
public function test_payment_approved_by_accountant_mail(): void
{
$member = Member::factory()->create();
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'status' => MembershipPayment::STATUS_APPROVED_ACCOUNTANT,
'amount' => 1000,
]);
$mailable = new PaymentApprovedByAccountantMail($payment);
$mailable->assertSeeInHtml('Accountant');
$mailable->assertSeeInHtml('approved');
}
public function test_payment_fully_approved_mail(): void
{
$member = Member::factory()->create();
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'status' => MembershipPayment::STATUS_APPROVED_CHAIR,
'amount' => 1000,
]);
$mailable = new PaymentFullyApprovedMail($payment);
$mailable->assertSeeInHtml('approved');
$mailable->assertSeeInHtml('1,000');
}
public function test_payment_rejected_mail(): void
{
$member = Member::factory()->create();
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'status' => MembershipPayment::STATUS_REJECTED,
'rejection_reason' => 'Invalid receipt format',
'amount' => 1000,
]);
$mailable = new PaymentRejectedMail($payment);
$mailable->assertSeeInHtml('Invalid receipt format');
$mailable->assertSeeInHtml('rejected');
}
public function test_membership_activated_mail(): void
{
$member = Member::factory()->create([
'full_name' => 'John Doe',
'membership_status' => Member::STATUS_ACTIVE,
'membership_started_at' => now(),
'membership_expires_at' => now()->addYear(),
]);
$mailable = new MembershipActivatedMail($member);
$mailable->assertSeeInHtml('activated');
$mailable->assertSeeInHtml('John Doe');
}
public function test_all_emails_implement_should_queue(): void
{
$member = Member::factory()->create();
$payment = MembershipPayment::factory()->create(['member_id' => $member->id]);
$mailables = [
new MemberRegistrationWelcomeMail($member),
new PaymentSubmittedMail($payment, 'member'),
new PaymentApprovedByCashierMail($payment),
new PaymentApprovedByAccountantMail($payment),
new PaymentFullyApprovedMail($payment),
new PaymentRejectedMail($payment),
new MembershipActivatedMail($member),
];
foreach ($mailables as $mailable) {
$this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $mailable);
}
}
public function test_emails_queued_correctly(): void
{
Mail::fake();
$member = Member::factory()->create();
$payment = MembershipPayment::factory()->create(['member_id' => $member->id]);
Mail::to($member->email)->queue(new PaymentSubmittedMail($payment, 'member'));
Mail::assertQueued(PaymentSubmittedMail::class);
}
public function test_email_recipients_correct(): void
{
Mail::fake();
$member = Member::factory()->create(['email' => 'member@example.com']);
$payment = MembershipPayment::factory()->create(['member_id' => $member->id]);
Mail::to($member->email)->queue(new PaymentSubmittedMail($payment, 'member'));
Mail::assertQueued(PaymentSubmittedMail::class, function ($mail) {
return $mail->hasTo('member@example.com');
});
}
public function test_payment_submitted_mail_has_correct_subject(): void
{
$member = Member::factory()->create();
$payment = MembershipPayment::factory()->create(['member_id' => $member->id]);
$mailable = new PaymentSubmittedMail($payment, 'member');
$this->assertStringContainsString('Payment', $mailable->subject ?? '');
}
public function test_rejection_mail_includes_next_steps(): void
{
$member = Member::factory()->create();
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'status' => MembershipPayment::STATUS_REJECTED,
'rejection_reason' => 'Test reason',
]);
$mailable = new PaymentRejectedMail($payment);
$mailable->assertSeeInHtml('Submit New Payment');
}
public function test_activation_mail_includes_expiry_date(): void
{
$member = Member::factory()->create([
'membership_status' => Member::STATUS_ACTIVE,
'membership_expires_at' => now()->addYear(),
]);
$mailable = new MembershipActivatedMail($member);
$expiryDate = $member->membership_expires_at->format('Y-m-d');
$mailable->assertSeeInHtml($expiryDate);
}
public function test_welcome_mail_includes_dashboard_link(): void
{
$member = Member::factory()->create();
$mailable = new MemberRegistrationWelcomeMail($member);
$mailable->assertSeeInHtml(route('member.dashboard'));
}
public function test_payment_fully_approved_mail_mentions_activation(): void
{
$member = Member::factory()->create();
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'status' => MembershipPayment::STATUS_APPROVED_CHAIR,
]);
$mailable = new PaymentFullyApprovedMail($payment);
$mailable->assertSeeInHtml('activated');
}
public function test_mail_facades_work_correctly(): void
{
Mail::fake();
$member = Member::factory()->create();
$payment = MembershipPayment::factory()->create(['member_id' => $member->id]);
// Send various emails
Mail::to($member->email)->queue(new MemberRegistrationWelcomeMail($member));
Mail::to($member->email)->queue(new PaymentSubmittedMail($payment, 'member'));
Mail::to($member->email)->queue(new MembershipActivatedMail($member));
Mail::assertQueued(MemberRegistrationWelcomeMail::class);
Mail::assertQueued(PaymentSubmittedMail::class);
Mail::assertQueued(MembershipActivatedMail::class);
}
public function test_emails_not_sent_when_not_queued(): void
{
Mail::fake();
// Don't queue any emails
Mail::assertNothingQueued();
Mail::assertNothingSent();
}
public function test_multiple_recipients_can_receive_same_email(): void
{
Mail::fake();
$member = Member::factory()->create();
$payment = MembershipPayment::factory()->create(['member_id' => $member->id]);
// Send to multiple recipients
Mail::to(['admin1@example.com', 'admin2@example.com'])->queue(new PaymentSubmittedMail($payment, 'cashier'));
Mail::assertQueued(PaymentSubmittedMail::class, 1);
}
public function test_email_content_is_html_formatted(): void
{
$member = Member::factory()->create();
$mailable = new MemberRegistrationWelcomeMail($member);
$mailable->assertSeeInHtml('<');
$mailable->assertSeeInHtml('>');
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_the_application_returns_a_successful_response(): void
{
$response = $this->get('/');
$response->assertStatus(200);
}
}

View File

@@ -0,0 +1,352 @@
<?php
namespace Tests\Feature;
use App\Models\FinanceDocument;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
/**
* Financial Document Workflow Feature Tests
*
* Tests the complete financial document workflow including:
* - Amount-based routing
* - Multi-stage approval
* - Permission-based access control
*/
class FinanceDocumentWorkflowTest extends TestCase
{
use RefreshDatabase, WithFaker;
protected User $requester;
protected User $cashier;
protected User $accountant;
protected User $chair;
protected User $boardMember;
protected function setUp(): void
{
parent::setUp();
// Create roles
Role::create(['name' => 'finance_requester']);
Role::create(['name' => 'finance_cashier']);
Role::create(['name' => 'finance_accountant']);
Role::create(['name' => 'finance_chair']);
Role::create(['name' => 'finance_board_member']);
// Create test users
$this->requester = User::factory()->create(['email' => 'requester@test.com']);
$this->cashier = User::factory()->create(['email' => 'cashier@test.com']);
$this->accountant = User::factory()->create(['email' => 'accountant@test.com']);
$this->chair = User::factory()->create(['email' => 'chair@test.com']);
$this->boardMember = User::factory()->create(['email' => 'board@test.com']);
// Assign roles
$this->requester->assignRole('finance_requester');
$this->cashier->assignRole('finance_cashier');
$this->accountant->assignRole('finance_accountant');
$this->chair->assignRole('finance_chair');
$this->boardMember->assignRole('finance_board_member');
// Give permissions
$this->requester->givePermissionTo('create_finance_document');
$this->cashier->givePermissionTo(['view_finance_documents', 'approve_as_cashier']);
$this->accountant->givePermissionTo(['view_finance_documents', 'approve_as_accountant']);
$this->chair->givePermissionTo(['view_finance_documents', 'approve_as_chair']);
$this->boardMember->givePermissionTo('approve_board_meeting');
}
/** @test */
public function small_amount_workflow_completes_without_chair()
{
// Create a small amount document (< 5000)
$document = FinanceDocument::create([
'title' => 'Small Expense Reimbursement',
'description' => 'Test small expense',
'amount' => 3000,
'request_type' => 'expense_reimbursement',
'status' => 'pending',
'submitted_by_id' => $this->requester->id,
'submitted_at' => now(),
]);
$document->amount_tier = $document->determineAmountTier();
$document->save();
$this->assertEquals('small', $document->amount_tier);
// Cashier approves
$this->actingAs($this->cashier);
$document->status = FinanceDocument::STATUS_APPROVED_CASHIER;
$document->cashier_approved_by_id = $this->cashier->id;
$document->cashier_approved_at = now();
$document->save();
$this->assertFalse($document->isApprovalStageComplete());
// Accountant approves (should complete workflow for small amounts)
$this->actingAs($this->accountant);
$document->status = FinanceDocument::STATUS_APPROVED_ACCOUNTANT;
$document->accountant_approved_by_id = $this->accountant->id;
$document->accountant_approved_at = now();
$document->save();
$this->assertTrue($document->isApprovalStageComplete());
$this->assertEquals('approval', $document->getCurrentWorkflowStage()); // Ready for payment stage
}
/** @test */
public function medium_amount_workflow_requires_chair_approval()
{
// Create a medium amount document (5000-50000)
$document = FinanceDocument::create([
'title' => 'Medium Purchase Request',
'description' => 'Test medium purchase',
'amount' => 25000,
'request_type' => 'purchase_request',
'status' => 'pending',
'submitted_by_id' => $this->requester->id,
'submitted_at' => now(),
]);
$document->amount_tier = $document->determineAmountTier();
$document->save();
$this->assertEquals('medium', $document->amount_tier);
$this->assertFalse($document->needsBoardMeetingApproval());
// Cashier approves
$document->status = FinanceDocument::STATUS_APPROVED_CASHIER;
$document->cashier_approved_by_id = $this->cashier->id;
$document->cashier_approved_at = now();
$document->save();
$this->assertFalse($document->isApprovalStageComplete());
// Accountant approves
$document->status = FinanceDocument::STATUS_APPROVED_ACCOUNTANT;
$document->accountant_approved_by_id = $this->accountant->id;
$document->accountant_approved_at = now();
$document->save();
$this->assertFalse($document->isApprovalStageComplete()); // Still needs chair
// Chair approves (should complete workflow for medium amounts)
$document->status = FinanceDocument::STATUS_APPROVED_CHAIR;
$document->chair_approved_by_id = $this->chair->id;
$document->chair_approved_at = now();
$document->save();
$this->assertTrue($document->isApprovalStageComplete());
}
/** @test */
public function large_amount_workflow_requires_board_meeting_approval()
{
// Create a large amount document (> 50000)
$document = FinanceDocument::create([
'title' => 'Large Capital Expenditure',
'description' => 'Test large expenditure',
'amount' => 75000,
'request_type' => 'purchase_request',
'status' => 'pending',
'submitted_by_id' => $this->requester->id,
'submitted_at' => now(),
]);
$document->amount_tier = $document->determineAmountTier();
$document->requires_board_meeting = $document->needsBoardMeetingApproval();
$document->save();
$this->assertEquals('large', $document->amount_tier);
$this->assertTrue($document->requires_board_meeting);
// Cashier approves
$document->status = FinanceDocument::STATUS_APPROVED_CASHIER;
$document->cashier_approved_by_id = $this->cashier->id;
$document->cashier_approved_at = now();
$document->save();
// Accountant approves
$document->status = FinanceDocument::STATUS_APPROVED_ACCOUNTANT;
$document->accountant_approved_by_id = $this->accountant->id;
$document->accountant_approved_at = now();
$document->save();
// Chair approves
$document->status = FinanceDocument::STATUS_APPROVED_CHAIR;
$document->chair_approved_by_id = $this->chair->id;
$document->chair_approved_at = now();
$document->save();
$this->assertFalse($document->isApprovalStageComplete()); // Still needs board meeting
// Board meeting approval
$document->board_meeting_approved_at = now();
$document->board_meeting_approved_by_id = $this->boardMember->id;
$document->save();
$this->assertTrue($document->isApprovalStageComplete());
}
/** @test */
public function requester_can_create_finance_document()
{
Storage::fake('local');
$this->actingAs($this->requester);
$file = UploadedFile::fake()->create('receipt.pdf', 100);
$response = $this->post(route('admin.finance.store'), [
'title' => 'Test Expense',
'description' => 'Test description',
'amount' => 5000,
'request_type' => 'expense_reimbursement',
'attachment' => $file,
]);
$response->assertRedirect();
$this->assertDatabaseHas('finance_documents', [
'title' => 'Test Expense',
'amount' => 5000,
'submitted_by_id' => $this->requester->id,
]);
}
/** @test */
public function cashier_cannot_approve_own_submission()
{
$document = FinanceDocument::create([
'title' => 'Self Submitted',
'description' => 'Test',
'amount' => 1000,
'request_type' => 'petty_cash',
'status' => 'pending',
'submitted_by_id' => $this->cashier->id,
'submitted_at' => now(),
]);
$this->assertFalse($document->canBeApprovedByCashier($this->cashier));
}
/** @test */
public function accountant_cannot_approve_before_cashier()
{
$document = FinanceDocument::create([
'title' => 'Pending Document',
'description' => 'Test',
'amount' => 1000,
'request_type' => 'petty_cash',
'status' => 'pending',
'submitted_by_id' => $this->requester->id,
'submitted_at' => now(),
]);
$this->assertFalse($document->canBeApprovedByAccountant());
}
/** @test */
public function rejected_document_cannot_proceed()
{
$document = FinanceDocument::create([
'title' => 'Rejected Document',
'description' => 'Test',
'amount' => 1000,
'request_type' => 'petty_cash',
'status' => FinanceDocument::STATUS_REJECTED,
'submitted_by_id' => $this->requester->id,
'submitted_at' => now(),
]);
$this->assertFalse($document->canBeApprovedByCashier($this->cashier));
$this->assertFalse($document->canBeApprovedByAccountant());
$this->assertFalse($document->canBeApprovedByChair());
}
/** @test */
public function workflow_stages_are_correctly_identified()
{
$document = FinanceDocument::create([
'title' => 'Test Document',
'description' => 'Test',
'amount' => 1000,
'request_type' => 'petty_cash',
'status' => 'pending',
'submitted_by_id' => $this->requester->id,
'submitted_at' => now(),
]);
$document->amount_tier = 'small';
$document->save();
// Stage 1: Approval
$this->assertEquals('approval', $document->getCurrentWorkflowStage());
$this->assertFalse($document->isPaymentCompleted());
$this->assertFalse($document->isRecordingComplete());
// Complete approval
$document->status = FinanceDocument::STATUS_APPROVED_ACCOUNTANT;
$document->cashier_approved_at = now();
$document->accountant_approved_at = now();
$document->save();
$this->assertTrue($document->isApprovalStageComplete());
// Stage 2: Payment (simulate payment order created and executed)
$document->payment_order_created_at = now();
$document->payment_verified_at = now();
$document->payment_executed_at = now();
$document->save();
$this->assertTrue($document->isPaymentCompleted());
$this->assertEquals('payment', $document->getCurrentWorkflowStage());
// Stage 3: Recording
$document->cashier_recorded_at = now();
$document->save();
$this->assertTrue($document->isRecordingComplete());
// Stage 4: Reconciliation
$this->assertEquals('reconciliation', $document->getCurrentWorkflowStage());
$document->bank_reconciliation_id = 1; // Simulate reconciliation
$document->save();
$this->assertTrue($document->isReconciled());
$this->assertEquals('completed', $document->getCurrentWorkflowStage());
}
/** @test */
public function amount_tier_is_automatically_determined()
{
$smallDoc = FinanceDocument::factory()->make(['amount' => 3000]);
$this->assertEquals('small', $smallDoc->determineAmountTier());
$mediumDoc = FinanceDocument::factory()->make(['amount' => 25000]);
$this->assertEquals('medium', $mediumDoc->determineAmountTier());
$largeDoc = FinanceDocument::factory()->make(['amount' => 75000]);
$this->assertEquals('large', $largeDoc->determineAmountTier());
}
/** @test */
public function board_meeting_requirement_is_correctly_identified()
{
$smallDoc = FinanceDocument::factory()->make(['amount' => 3000]);
$this->assertFalse($smallDoc->needsBoardMeetingApproval());
$mediumDoc = FinanceDocument::factory()->make(['amount' => 25000]);
$this->assertFalse($mediumDoc->needsBoardMeetingApproval());
$largeDoc = FinanceDocument::factory()->make(['amount' => 75000]);
$this->assertTrue($largeDoc->needsBoardMeetingApproval());
}
}

View File

@@ -0,0 +1,261 @@
<?php
namespace Tests\Feature;
use App\Mail\MemberRegistrationWelcomeMail;
use App\Models\Member;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class MemberRegistrationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->artisan('db:seed', ['--class' => 'RoleSeeder']);
}
public function test_public_registration_form_is_accessible(): void
{
$response = $this->get(route('register.member'));
$response->assertStatus(200);
$response->assertSee('Register');
$response->assertSee('Full Name');
$response->assertSee('Email');
$response->assertSee('Password');
}
public function test_can_register_with_valid_data(): void
{
Mail::fake();
$response = $this->post(route('register.member.store'), [
'full_name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'Password123!',
'password_confirmation' => 'Password123!',
'phone' => '0912345678',
'address_line_1' => '123 Test St',
'city' => 'Taipei',
'postal_code' => '100',
'emergency_contact_name' => 'Jane Doe',
'emergency_contact_phone' => '0987654321',
'terms_accepted' => true,
]);
$response->assertRedirect(route('member.dashboard'));
$response->assertSessionHas('status');
// Verify user created
$this->assertDatabaseHas('users', [
'email' => 'john@example.com',
]);
// Verify member created
$this->assertDatabaseHas('members', [
'full_name' => 'John Doe',
'email' => 'john@example.com',
'phone' => '0912345678',
'membership_status' => Member::STATUS_PENDING,
]);
}
public function test_user_and_member_records_created(): void
{
Mail::fake();
$this->post(route('register.member.store'), [
'full_name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'Password123!',
'password_confirmation' => 'Password123!',
'phone' => '0912345678',
'terms_accepted' => true,
]);
$user = User::where('email', 'john@example.com')->first();
$member = Member::where('email', 'john@example.com')->first();
$this->assertNotNull($user);
$this->assertNotNull($member);
$this->assertEquals($user->id, $member->user_id);
$this->assertTrue(Hash::check('Password123!', $user->password));
}
public function test_user_is_auto_logged_in_after_registration(): void
{
Mail::fake();
$response = $this->post(route('register.member.store'), [
'full_name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'Password123!',
'password_confirmation' => 'Password123!',
'terms_accepted' => true,
]);
$this->assertAuthenticated();
$user = User::where('email', 'john@example.com')->first();
$this->assertEquals($user->id, auth()->id());
}
public function test_welcome_email_is_sent(): void
{
Mail::fake();
$this->post(route('register.member.store'), [
'full_name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'Password123!',
'password_confirmation' => 'Password123!',
'terms_accepted' => true,
]);
Mail::assertQueued(MemberRegistrationWelcomeMail::class, function ($mail) {
return $mail->hasTo('john@example.com');
});
}
public function test_validation_fails_with_invalid_email(): void
{
$response = $this->post(route('register.member.store'), [
'full_name' => 'John Doe',
'email' => 'invalid-email',
'password' => 'Password123!',
'password_confirmation' => 'Password123!',
'terms_accepted' => true,
]);
$response->assertSessionHasErrors('email');
$this->assertDatabaseCount('users', 0);
$this->assertDatabaseCount('members', 0);
}
public function test_validation_fails_with_duplicate_email(): void
{
// Create existing user
User::factory()->create(['email' => 'existing@example.com']);
$response = $this->post(route('register.member.store'), [
'full_name' => 'John Doe',
'email' => 'existing@example.com',
'password' => 'Password123!',
'password_confirmation' => 'Password123!',
'terms_accepted' => true,
]);
$response->assertSessionHasErrors('email');
}
public function test_validation_fails_with_duplicate_email_in_members(): void
{
// Create existing member without user
Member::factory()->create(['email' => 'existing@example.com', 'user_id' => null]);
$response = $this->post(route('register.member.store'), [
'full_name' => 'John Doe',
'email' => 'existing@example.com',
'password' => 'Password123!',
'password_confirmation' => 'Password123!',
'terms_accepted' => true,
]);
$response->assertSessionHasErrors('email');
}
public function test_password_confirmation_required(): void
{
$response = $this->post(route('register.member.store'), [
'full_name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'Password123!',
'password_confirmation' => 'DifferentPassword',
'terms_accepted' => true,
]);
$response->assertSessionHasErrors('password');
$this->assertDatabaseCount('users', 0);
}
public function test_terms_acceptance_required(): void
{
$response = $this->post(route('register.member.store'), [
'full_name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'Password123!',
'password_confirmation' => 'Password123!',
'terms_accepted' => false,
]);
$response->assertSessionHasErrors('terms_accepted');
$this->assertDatabaseCount('users', 0);
}
public function test_registration_creates_audit_log(): void
{
Mail::fake();
$this->post(route('register.member.store'), [
'full_name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'Password123!',
'password_confirmation' => 'Password123!',
'terms_accepted' => true,
]);
$this->assertDatabaseHas('audit_logs', [
'action' => 'member.self_registered',
]);
}
public function test_member_status_is_pending_after_registration(): void
{
Mail::fake();
$this->post(route('register.member.store'), [
'full_name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'Password123!',
'password_confirmation' => 'Password123!',
'terms_accepted' => true,
]);
$member = Member::where('email', 'john@example.com')->first();
$this->assertEquals(Member::STATUS_PENDING, $member->membership_status);
$this->assertEquals(Member::TYPE_REGULAR, $member->membership_type);
}
public function test_required_fields_validation(): void
{
$response = $this->post(route('register.member.store'), []);
$response->assertSessionHasErrors(['full_name', 'email', 'password', 'terms_accepted']);
}
public function test_optional_fields_can_be_null(): void
{
Mail::fake();
$response = $this->post(route('register.member.store'), [
'full_name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'Password123!',
'password_confirmation' => 'Password123!',
'terms_accepted' => true,
// Optional fields omitted
]);
$response->assertRedirect(route('member.dashboard'));
$member = Member::where('email', 'john@example.com')->first();
$this->assertNull($member->phone);
$this->assertNull($member->address_line_1);
}
}

View File

@@ -0,0 +1,311 @@
<?php
namespace Tests\Feature;
use App\Models\FinanceDocument;
use App\Models\PaymentOrder;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
/**
* Payment Order Workflow Feature Tests
*
* Tests payment order creation, verification, and execution
*/
class PaymentOrderWorkflowTest extends TestCase
{
use RefreshDatabase, WithFaker;
protected User $accountant;
protected User $cashier;
protected FinanceDocument $approvedDocument;
protected function setUp(): void
{
parent::setUp();
Role::create(['name' => 'finance_accountant']);
Role::create(['name' => 'finance_cashier']);
$this->accountant = User::factory()->create(['email' => 'accountant@test.com']);
$this->cashier = User::factory()->create(['email' => 'cashier@test.com']);
$this->accountant->assignRole('finance_accountant');
$this->cashier->assignRole('finance_cashier');
$this->accountant->givePermissionTo(['create_payment_order', 'view_payment_orders']);
$this->cashier->givePermissionTo(['verify_payment_order', 'execute_payment', 'view_payment_orders']);
// Create an approved finance document
$this->approvedDocument = FinanceDocument::create([
'title' => 'Approved Document',
'description' => 'Test',
'amount' => 5000,
'request_type' => 'expense_reimbursement',
'status' => FinanceDocument::STATUS_APPROVED_ACCOUNTANT,
'submitted_by_id' => $this->accountant->id,
'submitted_at' => now(),
'cashier_approved_at' => now(),
'accountant_approved_at' => now(),
'amount_tier' => 'small',
]);
}
/** @test */
public function accountant_can_create_payment_order_for_approved_document()
{
$this->actingAs($this->accountant);
$response = $this->post(route('admin.payment-orders.store'), [
'finance_document_id' => $this->approvedDocument->id,
'payee_name' => 'John Doe',
'payment_amount' => 5000,
'payment_method' => 'bank_transfer',
'payee_bank_name' => 'Test Bank',
'payee_bank_code' => '012',
'payee_account_number' => '1234567890',
'notes' => 'Test payment order',
]);
$response->assertRedirect();
$this->assertDatabaseHas('payment_orders', [
'finance_document_id' => $this->approvedDocument->id,
'payee_name' => 'John Doe',
'payment_amount' => 5000,
'status' => 'pending_verification',
]);
// Check finance document is updated
$this->approvedDocument->refresh();
$this->assertNotNull($this->approvedDocument->payment_order_created_at);
}
/** @test */
public function payment_order_number_is_automatically_generated()
{
$paymentOrder = PaymentOrder::create([
'finance_document_id' => $this->approvedDocument->id,
'payment_order_number' => PaymentOrder::generatePaymentOrderNumber(),
'payee_name' => 'Test Payee',
'payment_amount' => 5000,
'payment_method' => 'cash',
'status' => 'pending_verification',
'created_by_accountant_id' => $this->accountant->id,
]);
$this->assertNotEmpty($paymentOrder->payment_order_number);
$this->assertStringStartsWith('PO-', $paymentOrder->payment_order_number);
}
/** @test */
public function cashier_can_verify_payment_order()
{
$paymentOrder = PaymentOrder::create([
'finance_document_id' => $this->approvedDocument->id,
'payment_order_number' => PaymentOrder::generatePaymentOrderNumber(),
'payee_name' => 'Test Payee',
'payment_amount' => 5000,
'payment_method' => 'cash',
'status' => 'pending_verification',
'created_by_accountant_id' => $this->accountant->id,
]);
$this->actingAs($this->cashier);
$response = $this->post(route('admin.payment-orders.verify', $paymentOrder), [
'action' => 'approve',
'verification_notes' => 'Verified and approved',
]);
$response->assertRedirect();
$paymentOrder->refresh();
$this->assertEquals('approved', $paymentOrder->verification_status);
$this->assertEquals('verified', $paymentOrder->status);
$this->assertNotNull($paymentOrder->verified_at);
$this->assertEquals($this->cashier->id, $paymentOrder->verified_by_cashier_id);
// Check finance document is updated
$this->approvedDocument->refresh();
$this->assertNotNull($this->approvedDocument->payment_verified_at);
}
/** @test */
public function cashier_can_reject_payment_order_during_verification()
{
$paymentOrder = PaymentOrder::create([
'finance_document_id' => $this->approvedDocument->id,
'payment_order_number' => PaymentOrder::generatePaymentOrderNumber(),
'payee_name' => 'Test Payee',
'payment_amount' => 5000,
'payment_method' => 'cash',
'status' => 'pending_verification',
'created_by_accountant_id' => $this->accountant->id,
]);
$this->actingAs($this->cashier);
$response = $this->post(route('admin.payment-orders.verify', $paymentOrder), [
'action' => 'reject',
'verification_notes' => 'Incorrect amount',
]);
$response->assertRedirect();
$paymentOrder->refresh();
$this->assertEquals('rejected', $paymentOrder->verification_status);
$this->assertNotNull($paymentOrder->verified_at);
}
/** @test */
public function cashier_can_execute_verified_payment_order()
{
Storage::fake('local');
$paymentOrder = PaymentOrder::create([
'finance_document_id' => $this->approvedDocument->id,
'payment_order_number' => PaymentOrder::generatePaymentOrderNumber(),
'payee_name' => 'Test Payee',
'payment_amount' => 5000,
'payment_method' => 'bank_transfer',
'status' => 'verified',
'verification_status' => 'approved',
'created_by_accountant_id' => $this->accountant->id,
'verified_by_cashier_id' => $this->cashier->id,
'verified_at' => now(),
]);
$this->actingAs($this->cashier);
$receipt = UploadedFile::fake()->create('receipt.pdf', 100);
$response = $this->post(route('admin.payment-orders.execute', $paymentOrder), [
'transaction_reference' => 'TXN123456',
'payment_receipt' => $receipt,
'execution_notes' => 'Payment completed successfully',
]);
$response->assertRedirect();
$paymentOrder->refresh();
$this->assertEquals('executed', $paymentOrder->status);
$this->assertEquals('completed', $paymentOrder->execution_status);
$this->assertNotNull($paymentOrder->executed_at);
$this->assertEquals($this->cashier->id, $paymentOrder->executed_by_cashier_id);
$this->assertEquals('TXN123456', $paymentOrder->transaction_reference);
// Check finance document is updated
$this->approvedDocument->refresh();
$this->assertNotNull($this->approvedDocument->payment_executed_at);
}
/** @test */
public function cannot_execute_unverified_payment_order()
{
$paymentOrder = PaymentOrder::create([
'finance_document_id' => $this->approvedDocument->id,
'payment_order_number' => PaymentOrder::generatePaymentOrderNumber(),
'payee_name' => 'Test Payee',
'payment_amount' => 5000,
'payment_method' => 'cash',
'status' => 'pending_verification',
'created_by_accountant_id' => $this->accountant->id,
]);
$this->assertFalse($paymentOrder->canBeExecuted());
}
/** @test */
public function cannot_verify_already_verified_payment_order()
{
$paymentOrder = PaymentOrder::create([
'finance_document_id' => $this->approvedDocument->id,
'payment_order_number' => PaymentOrder::generatePaymentOrderNumber(),
'payee_name' => 'Test Payee',
'payment_amount' => 5000,
'payment_method' => 'cash',
'status' => 'verified',
'verification_status' => 'approved',
'created_by_accountant_id' => $this->accountant->id,
'verified_by_cashier_id' => $this->cashier->id,
'verified_at' => now(),
]);
$this->assertFalse($paymentOrder->canBeVerifiedByCashier());
}
/** @test */
public function accountant_can_cancel_unexecuted_payment_order()
{
$paymentOrder = PaymentOrder::create([
'finance_document_id' => $this->approvedDocument->id,
'payment_order_number' => PaymentOrder::generatePaymentOrderNumber(),
'payee_name' => 'Test Payee',
'payment_amount' => 5000,
'payment_method' => 'cash',
'status' => 'pending_verification',
'created_by_accountant_id' => $this->accountant->id,
]);
$this->actingAs($this->accountant);
$response = $this->post(route('admin.payment-orders.cancel', $paymentOrder));
$response->assertRedirect();
$paymentOrder->refresh();
$this->assertEquals('cancelled', $paymentOrder->status);
}
/** @test */
public function payment_order_for_different_payment_methods()
{
// Test cash payment
$cashOrder = PaymentOrder::create([
'finance_document_id' => $this->approvedDocument->id,
'payment_order_number' => PaymentOrder::generatePaymentOrderNumber(),
'payee_name' => 'Test Payee',
'payment_amount' => 1000,
'payment_method' => 'cash',
'status' => 'pending_verification',
'created_by_accountant_id' => $this->accountant->id,
]);
$this->assertEquals('現金', $cashOrder->getPaymentMethodText());
// Test check payment
$checkOrder = PaymentOrder::create([
'finance_document_id' => $this->approvedDocument->id,
'payment_order_number' => PaymentOrder::generatePaymentOrderNumber(),
'payee_name' => 'Test Payee',
'payment_amount' => 2000,
'payment_method' => 'check',
'status' => 'pending_verification',
'created_by_accountant_id' => $this->accountant->id,
]);
$this->assertEquals('支票', $checkOrder->getPaymentMethodText());
// Test bank transfer
$transferOrder = PaymentOrder::create([
'finance_document_id' => $this->approvedDocument->id,
'payment_order_number' => PaymentOrder::generatePaymentOrderNumber(),
'payee_name' => 'Test Payee',
'payment_amount' => 3000,
'payment_method' => 'bank_transfer',
'payee_bank_name' => 'Test Bank',
'payee_bank_code' => '012',
'payee_account_number' => '1234567890',
'status' => 'pending_verification',
'created_by_accountant_id' => $this->accountant->id,
]);
$this->assertEquals('銀行轉帳', $transferOrder->getPaymentMethodText());
}
}

View File

@@ -0,0 +1,488 @@
<?php
namespace Tests\Feature;
use App\Mail\MembershipActivatedMail;
use App\Mail\PaymentApprovedByAccountantMail;
use App\Mail\PaymentApprovedByCashierMail;
use App\Mail\PaymentFullyApprovedMail;
use App\Mail\PaymentRejectedMail;
use App\Mail\PaymentSubmittedMail;
use App\Models\Member;
use App\Models\MembershipPayment;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class PaymentVerificationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('private');
$this->artisan('db:seed', ['--class' => 'RoleSeeder']);
$this->artisan('db:seed', ['--class' => 'PaymentVerificationRolesSeeder']);
}
public function test_member_can_submit_payment_with_receipt(): void
{
Mail::fake();
$user = User::factory()->create();
$member = Member::factory()->create([
'user_id' => $user->id,
'membership_status' => Member::STATUS_PENDING,
]);
$file = UploadedFile::fake()->image('receipt.jpg');
$response = $this->actingAs($user)->post(route('member.payments.store'), [
'amount' => 1000,
'paid_at' => now()->format('Y-m-d'),
'payment_method' => MembershipPayment::METHOD_BANK_TRANSFER,
'reference' => 'ATM123456',
'receipt' => $file,
'notes' => 'Annual membership fee',
]);
$response->assertRedirect(route('member.dashboard'));
$response->assertSessionHas('status');
$this->assertDatabaseHas('membership_payments', [
'member_id' => $member->id,
'amount' => 1000,
'status' => MembershipPayment::STATUS_PENDING,
'payment_method' => MembershipPayment::METHOD_BANK_TRANSFER,
]);
}
public function test_receipt_is_stored_in_private_storage(): void
{
Mail::fake();
Storage::fake('private');
$user = User::factory()->create();
$member = Member::factory()->create([
'user_id' => $user->id,
'membership_status' => Member::STATUS_PENDING,
]);
$file = UploadedFile::fake()->image('receipt.jpg');
$this->actingAs($user)->post(route('member.payments.store'), [
'amount' => 1000,
'paid_at' => now()->format('Y-m-d'),
'payment_method' => MembershipPayment::METHOD_BANK_TRANSFER,
'receipt' => $file,
]);
$payment = MembershipPayment::first();
$this->assertNotNull($payment->receipt_path);
Storage::disk('private')->assertExists($payment->receipt_path);
}
public function test_payment_starts_with_pending_status(): void
{
Mail::fake();
$user = User::factory()->create();
$member = Member::factory()->create([
'user_id' => $user->id,
'membership_status' => Member::STATUS_PENDING,
]);
$file = UploadedFile::fake()->image('receipt.jpg');
$this->actingAs($user)->post(route('member.payments.store'), [
'amount' => 1000,
'paid_at' => now()->format('Y-m-d'),
'payment_method' => MembershipPayment::METHOD_BANK_TRANSFER,
'receipt' => $file,
]);
$payment = MembershipPayment::first();
$this->assertEquals(MembershipPayment::STATUS_PENDING, $payment->status);
$this->assertTrue($payment->isPending());
}
public function test_submission_emails_sent_to_member_and_cashiers(): void
{
Mail::fake();
$cashier = User::factory()->create();
$cashier->givePermissionTo('verify_payments_cashier');
$user = User::factory()->create();
$member = Member::factory()->create([
'user_id' => $user->id,
'membership_status' => Member::STATUS_PENDING,
]);
$file = UploadedFile::fake()->image('receipt.jpg');
$this->actingAs($user)->post(route('member.payments.store'), [
'amount' => 1000,
'paid_at' => now()->format('Y-m-d'),
'payment_method' => MembershipPayment::METHOD_BANK_TRANSFER,
'receipt' => $file,
]);
Mail::assertQueued(PaymentSubmittedMail::class, 2); // Member + Cashier
}
public function test_cashier_can_approve_tier_1(): void
{
Mail::fake();
$cashier = User::factory()->create();
$cashier->givePermissionTo('verify_payments_cashier');
$member = Member::factory()->create();
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'status' => MembershipPayment::STATUS_PENDING,
]);
$response = $this->actingAs($cashier)->post(
route('admin.payment-verifications.approve-cashier', $payment),
['notes' => 'Receipt verified']
);
$response->assertRedirect(route('admin.payment-verifications.index'));
$payment->refresh();
$this->assertEquals(MembershipPayment::STATUS_APPROVED_CASHIER, $payment->status);
$this->assertEquals($cashier->id, $payment->verified_by_cashier_id);
$this->assertNotNull($payment->cashier_verified_at);
}
public function test_cashier_approval_sends_email_to_accountants(): void
{
Mail::fake();
$cashier = User::factory()->create();
$cashier->givePermissionTo('verify_payments_cashier');
$accountant = User::factory()->create();
$accountant->givePermissionTo('verify_payments_accountant');
$member = Member::factory()->create();
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'status' => MembershipPayment::STATUS_PENDING,
]);
$this->actingAs($cashier)->post(
route('admin.payment-verifications.approve-cashier', $payment)
);
Mail::assertQueued(PaymentApprovedByCashierMail::class);
}
public function test_accountant_can_approve_tier_2(): void
{
Mail::fake();
$accountant = User::factory()->create();
$accountant->givePermissionTo('verify_payments_accountant');
$member = Member::factory()->create();
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'status' => MembershipPayment::STATUS_APPROVED_CASHIER,
]);
$response = $this->actingAs($accountant)->post(
route('admin.payment-verifications.approve-accountant', $payment),
['notes' => 'Amount verified']
);
$response->assertRedirect(route('admin.payment-verifications.index'));
$payment->refresh();
$this->assertEquals(MembershipPayment::STATUS_APPROVED_ACCOUNTANT, $payment->status);
$this->assertEquals($accountant->id, $payment->verified_by_accountant_id);
$this->assertNotNull($payment->accountant_verified_at);
}
public function test_accountant_approval_sends_email_to_chairs(): void
{
Mail::fake();
$accountant = User::factory()->create();
$accountant->givePermissionTo('verify_payments_accountant');
$chair = User::factory()->create();
$chair->givePermissionTo('verify_payments_chair');
$member = Member::factory()->create();
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'status' => MembershipPayment::STATUS_APPROVED_CASHIER,
]);
$this->actingAs($accountant)->post(
route('admin.payment-verifications.approve-accountant', $payment)
);
Mail::assertQueued(PaymentApprovedByAccountantMail::class);
}
public function test_chair_can_approve_tier_3(): void
{
Mail::fake();
$chair = User::factory()->create();
$chair->givePermissionTo('verify_payments_chair');
$member = Member::factory()->create([
'membership_status' => Member::STATUS_PENDING,
]);
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'status' => MembershipPayment::STATUS_APPROVED_ACCOUNTANT,
]);
$response = $this->actingAs($chair)->post(
route('admin.payment-verifications.approve-chair', $payment),
['notes' => 'Final approval']
);
$response->assertRedirect(route('admin.payment-verifications.index'));
$payment->refresh();
$this->assertEquals(MembershipPayment::STATUS_APPROVED_CHAIR, $payment->status);
$this->assertEquals($chair->id, $payment->verified_by_chair_id);
$this->assertNotNull($payment->chair_verified_at);
$this->assertTrue($payment->isFullyApproved());
}
public function test_chair_approval_activates_membership_automatically(): void
{
Mail::fake();
$chair = User::factory()->create();
$chair->givePermissionTo('verify_payments_chair');
$member = Member::factory()->create([
'membership_status' => Member::STATUS_PENDING,
'membership_started_at' => null,
'membership_expires_at' => null,
]);
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'status' => MembershipPayment::STATUS_APPROVED_ACCOUNTANT,
]);
$this->actingAs($chair)->post(
route('admin.payment-verifications.approve-chair', $payment)
);
$member->refresh();
$this->assertEquals(Member::STATUS_ACTIVE, $member->membership_status);
$this->assertNotNull($member->membership_started_at);
$this->assertNotNull($member->membership_expires_at);
$this->assertTrue($member->hasPaidMembership());
}
public function test_activation_email_sent_to_member(): void
{
Mail::fake();
$chair = User::factory()->create();
$chair->givePermissionTo('verify_payments_chair');
$member = Member::factory()->create([
'membership_status' => Member::STATUS_PENDING,
]);
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'status' => MembershipPayment::STATUS_APPROVED_ACCOUNTANT,
]);
$this->actingAs($chair)->post(
route('admin.payment-verifications.approve-chair', $payment)
);
Mail::assertQueued(PaymentFullyApprovedMail::class);
Mail::assertQueued(MembershipActivatedMail::class);
}
public function test_cannot_skip_tiers_accountant_cant_approve_pending(): void
{
$accountant = User::factory()->create();
$accountant->givePermissionTo('verify_payments_accountant');
$member = Member::factory()->create();
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'status' => MembershipPayment::STATUS_PENDING,
]);
$response = $this->actingAs($accountant)->post(
route('admin.payment-verifications.approve-accountant', $payment)
);
$response->assertSessionHas('error');
$payment->refresh();
$this->assertEquals(MembershipPayment::STATUS_PENDING, $payment->status);
}
public function test_can_reject_at_any_tier_with_reason(): void
{
Mail::fake();
$cashier = User::factory()->create();
$cashier->givePermissionTo('verify_payments_cashier');
$member = Member::factory()->create();
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'status' => MembershipPayment::STATUS_PENDING,
]);
$response = $this->actingAs($cashier)->post(
route('admin.payment-verifications.reject', $payment),
['rejection_reason' => 'Invalid receipt']
);
$response->assertRedirect(route('admin.payment-verifications.index'));
$payment->refresh();
$this->assertEquals(MembershipPayment::STATUS_REJECTED, $payment->status);
$this->assertEquals('Invalid receipt', $payment->rejection_reason);
$this->assertEquals($cashier->id, $payment->rejected_by_user_id);
$this->assertNotNull($payment->rejected_at);
}
public function test_rejection_email_sent_with_reason(): void
{
Mail::fake();
$cashier = User::factory()->create();
$cashier->givePermissionTo('verify_payments_cashier');
$member = Member::factory()->create();
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'status' => MembershipPayment::STATUS_PENDING,
]);
$this->actingAs($cashier)->post(
route('admin.payment-verifications.reject', $payment),
['rejection_reason' => 'Invalid receipt']
);
Mail::assertQueued(PaymentRejectedMail::class, function ($mail) use ($payment) {
return $mail->payment->id === $payment->id;
});
}
public function test_dashboard_shows_correct_queues_based_on_permissions(): void
{
$admin = User::factory()->create();
$admin->givePermissionTo('view_payment_verifications');
// Create payments in different states
$pending = MembershipPayment::factory()->create(['status' => MembershipPayment::STATUS_PENDING]);
$cashierApproved = MembershipPayment::factory()->create(['status' => MembershipPayment::STATUS_APPROVED_CASHIER]);
$accountantApproved = MembershipPayment::factory()->create(['status' => MembershipPayment::STATUS_APPROVED_ACCOUNTANT]);
$response = $this->actingAs($admin)->get(route('admin.payment-verifications.index'));
$response->assertStatus(200);
$response->assertSee('Cashier Queue');
$response->assertSee('Accountant Queue');
$response->assertSee('Chair Queue');
}
public function test_user_without_permission_cannot_access_dashboard(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('admin.payment-verifications.index'));
$response->assertStatus(403);
}
public function test_audit_log_created_for_each_approval(): void
{
Mail::fake();
$cashier = User::factory()->create();
$cashier->givePermissionTo('verify_payments_cashier');
$member = Member::factory()->create();
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'status' => MembershipPayment::STATUS_PENDING,
]);
$this->actingAs($cashier)->post(
route('admin.payment-verifications.approve-cashier', $payment)
);
$this->assertDatabaseHas('audit_logs', [
'action' => 'payment.approved_by_cashier',
'user_id' => $cashier->id,
]);
}
public function test_complete_workflow_sequence(): void
{
Mail::fake();
// Setup users with permissions
$cashier = User::factory()->create();
$cashier->givePermissionTo('verify_payments_cashier');
$accountant = User::factory()->create();
$accountant->givePermissionTo('verify_payments_accountant');
$chair = User::factory()->create();
$chair->givePermissionTo('verify_payments_chair');
// Create member and payment
$member = Member::factory()->create([
'membership_status' => Member::STATUS_PENDING,
]);
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'status' => MembershipPayment::STATUS_PENDING,
]);
// Step 1: Cashier approves
$this->actingAs($cashier)->post(
route('admin.payment-verifications.approve-cashier', $payment)
);
$payment->refresh();
$this->assertTrue($payment->isApprovedByCashier());
// Step 2: Accountant approves
$this->actingAs($accountant)->post(
route('admin.payment-verifications.approve-accountant', $payment)
);
$payment->refresh();
$this->assertTrue($payment->isApprovedByAccountant());
// Step 3: Chair approves
$this->actingAs($chair)->post(
route('admin.payment-verifications.approve-chair', $payment)
);
$payment->refresh();
$this->assertTrue($payment->isFullyApproved());
// Verify member is activated
$member->refresh();
$this->assertEquals(Member::STATUS_ACTIVE, $member->membership_status);
$this->assertTrue($member->hasPaidMembership());
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProfileTest extends TestCase
{
use RefreshDatabase;
public function test_profile_page_is_displayed(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->get('/profile');
$response->assertOk();
}
public function test_profile_information_can_be_updated(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch('/profile', [
'name' => 'Test User',
'email' => 'test@example.com',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/profile');
$user->refresh();
$this->assertSame('Test User', $user->name);
$this->assertSame('test@example.com', $user->email);
$this->assertNull($user->email_verified_at);
}
public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch('/profile', [
'name' => 'Test User',
'email' => $user->email,
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/profile');
$this->assertNotNull($user->refresh()->email_verified_at);
}
public function test_user_can_delete_their_account(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->delete('/profile', [
'password' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/');
$this->assertGuest();
$this->assertNull($user->fresh());
}
public function test_correct_password_must_be_provided_to_delete_account(): void
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from('/profile')
->delete('/profile', [
'password' => 'wrong-password',
]);
$response
->assertSessionHasErrorsIn('userDeletion', 'password')
->assertRedirect('/profile');
$this->assertNotNull($user->fresh());
}
}