Initial commit
This commit is contained in:
21
tests/CreatesApplication.php
Normal file
21
tests/CreatesApplication.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Foundation\Application;
|
||||
|
||||
trait CreatesApplication
|
||||
{
|
||||
/**
|
||||
* Creates the application.
|
||||
*/
|
||||
public function createApplication(): Application
|
||||
{
|
||||
$app = require __DIR__.'/../bootstrap/app.php';
|
||||
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
|
||||
return $app;
|
||||
}
|
||||
}
|
||||
540
tests/FINANCIAL_WORKFLOW_TEST_PLAN.md
Normal file
540
tests/FINANCIAL_WORKFLOW_TEST_PLAN.md
Normal file
@@ -0,0 +1,540 @@
|
||||
# Financial Workflow System - Test Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the complete testing strategy for the financial workflow system implementing the "會計管帳,出納管錢" (Accountant manages books, Cashier manages money) principle.
|
||||
|
||||
---
|
||||
|
||||
## Test Environment Setup
|
||||
|
||||
### Prerequisites
|
||||
```bash
|
||||
# 1. Run setup script
|
||||
./setup-financial-workflow.sh
|
||||
|
||||
# 2. Verify test users created
|
||||
php artisan tinker
|
||||
>>> User::where('email', 'like', '%@test.com')->pluck('email', 'name')
|
||||
|
||||
# 3. Check permissions
|
||||
>>> Role::with('permissions')->get()
|
||||
```
|
||||
|
||||
### Test Users
|
||||
| Email | Password | Role | Purpose |
|
||||
|-------|----------|------|---------|
|
||||
| cashier@test.com | password | finance_cashier | Test cashier operations |
|
||||
| accountant@test.com | password | finance_accountant | Test accountant operations |
|
||||
| chair@test.com | password | finance_chair | Test chair approvals |
|
||||
| requester@test.com | password | finance_requester | Test document creation |
|
||||
|
||||
---
|
||||
|
||||
## 1. Manual Testing Checklist
|
||||
|
||||
### 1.1 Stage 1: Approval Workflow
|
||||
|
||||
#### Small Amount (< 5,000) - Cashier → Accountant
|
||||
- [ ] **Step 1**: Login as `requester@test.com`
|
||||
- [ ] Navigate to `/admin/finance-documents/create`
|
||||
- [ ] Create document with:
|
||||
- Title: "小額報銷測試"
|
||||
- Amount: 3,000
|
||||
- Request Type: expense_reimbursement
|
||||
- Upload attachment
|
||||
- [ ] Verify document created with status "pending"
|
||||
- [ ] Verify amount_tier automatically set to "small"
|
||||
|
||||
- [ ] **Step 2**: Login as `cashier@test.com`
|
||||
- [ ] Navigate to `/admin/finance-documents`
|
||||
- [ ] Find pending document
|
||||
- [ ] Click "Approve"
|
||||
- [ ] Verify status changed to "approved_cashier"
|
||||
- [ ] Verify email sent to accountant
|
||||
|
||||
- [ ] **Step 3**: Login as `accountant@test.com`
|
||||
- [ ] View document
|
||||
- [ ] Click "Approve"
|
||||
- [ ] Verify status changed to "approved_accountant"
|
||||
- [ ] Verify message shows "小額申請審核完成,可以製作付款單"
|
||||
- [ ] Verify "Create Payment Order" button appears
|
||||
|
||||
#### Medium Amount (5,000-50,000) - Cashier → Accountant → Chair
|
||||
- [ ] **Step 1**: Create document with amount: 25,000
|
||||
- [ ] **Step 2**: Cashier approves
|
||||
- [ ] **Step 3**: Accountant approves
|
||||
- [ ] Verify message shows "已送交理事長審核"
|
||||
- [ ] **Step 4**: Login as `chair@test.com`
|
||||
- [ ] Approve document
|
||||
- [ ] Verify status changed to "approved_chair"
|
||||
- [ ] Verify message shows "審核流程完成"
|
||||
|
||||
#### Large Amount (> 50,000) - Cashier → Accountant → Chair → Board
|
||||
- [ ] **Step 1**: Create document with amount: 75,000
|
||||
- [ ] **Step 2-4**: Complete cashier, accountant, chair approvals
|
||||
- [ ] **Step 5**: Verify `requires_board_meeting` flag is true
|
||||
- [ ] **Step 6**: Verify message shows "大額申請仍需理事會核准"
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Stage 2: Payment Workflow
|
||||
|
||||
#### Create Payment Order (Accountant)
|
||||
- [ ] **Step 1**: Login as `accountant@test.com`
|
||||
- [ ] **Step 2**: Navigate to approved document
|
||||
- [ ] **Step 3**: Click "製作付款單"
|
||||
- [ ] **Step 4**: Fill payment order form:
|
||||
- Payee Name: "Test Vendor"
|
||||
- Payment Method: "bank_transfer"
|
||||
- Bank Name: "Test Bank"
|
||||
- Bank Code: "007"
|
||||
- Account Number: "1234567890"
|
||||
- Amount: (auto-filled from document)
|
||||
- Notes: "測試付款單"
|
||||
- [ ] **Step 5**: Submit form
|
||||
- [ ] **Step 6**: Verify:
|
||||
- [ ] Payment order created with unique number (PO-YYYYMMDD-####)
|
||||
- [ ] Status is "pending_verification"
|
||||
- [ ] finance_document updated with payment_order_created_at
|
||||
- [ ] Redirect to payment order show page
|
||||
|
||||
#### Verify Payment Order (Cashier)
|
||||
- [ ] **Step 1**: Login as `cashier@test.com`
|
||||
- [ ] **Step 2**: Navigate to `/admin/payment-orders`
|
||||
- [ ] **Step 3**: Find pending payment order
|
||||
- [ ] **Step 4**: Click to view details
|
||||
- [ ] **Step 5**: Review payment information
|
||||
- [ ] **Step 6**: Option A - Approve:
|
||||
- [ ] Enter verification notes
|
||||
- [ ] Click "通過覆核"
|
||||
- [ ] Verify status changed to "verified"
|
||||
- [ ] Verify execution form appears
|
||||
- [ ] **Step 7**: Option B - Reject:
|
||||
- [ ] Enter rejection reason
|
||||
- [ ] Click "駁回"
|
||||
- [ ] Verify status changed to "cancelled"
|
||||
|
||||
#### Execute Payment (Cashier)
|
||||
- [ ] **Step 1**: With verified payment order
|
||||
- [ ] **Step 2**: Fill execution form:
|
||||
- Transaction Reference: "TXN-2025-001"
|
||||
- Upload payment receipt (PDF/image)
|
||||
- Execution notes: "已完成轉帳"
|
||||
- [ ] **Step 3**: Click "確認執行付款"
|
||||
- [ ] **Step 4**: Verify:
|
||||
- [ ] Status changed to "executed"
|
||||
- [ ] Execution status is "completed"
|
||||
- [ ] Receipt can be downloaded
|
||||
- [ ] finance_document updated with payment_executed_at
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Stage 3: Recording Workflow
|
||||
|
||||
#### Cashier Ledger Entry
|
||||
- [ ] **Step 1**: Login as `cashier@test.com`
|
||||
- [ ] **Step 2**: Navigate to `/admin/cashier-ledger/create`
|
||||
- [ ] **Step 3**: Fill form:
|
||||
- Finance Document: (select executed payment)
|
||||
- Entry Date: (today)
|
||||
- Entry Type: "payment"
|
||||
- Payment Method: "bank_transfer"
|
||||
- Bank Account: "Main Account"
|
||||
- Amount: (from payment order)
|
||||
- Receipt Number: "RCP-001"
|
||||
- Transaction Reference: (from payment order)
|
||||
- Notes: "記錄付款"
|
||||
- [ ] **Step 4**: Submit form
|
||||
- [ ] **Step 5**: Verify:
|
||||
- [ ] Entry created
|
||||
- [ ] Balance_before calculated from previous entry
|
||||
- [ ] Balance_after = balance_before - amount
|
||||
- [ ] finance_document updated with cashier_ledger_entry_id
|
||||
|
||||
#### Accounting Transaction (Accountant)
|
||||
- [ ] **Step 1**: Login as `accountant@test.com`
|
||||
- [ ] **Step 2**: Navigate to `/admin/transactions/create`
|
||||
- [ ] **Step 3**: Create accounting entry with debit/credit
|
||||
- [ ] **Step 4**: Link to finance document
|
||||
- [ ] **Step 5**: Verify transaction recorded
|
||||
|
||||
---
|
||||
|
||||
### 1.4 Stage 4: Reconciliation Workflow
|
||||
|
||||
#### Prepare Bank Reconciliation (Cashier)
|
||||
- [ ] **Step 1**: Login as `cashier@test.com`
|
||||
- [ ] **Step 2**: Navigate to `/admin/bank-reconciliations/create`
|
||||
- [ ] **Step 3**: Fill reconciliation form:
|
||||
- Reconciliation Month: "2025-11"
|
||||
- Bank Statement Balance: 500,000
|
||||
- Bank Statement Date: 2025-11-30
|
||||
- Upload bank statement (PDF)
|
||||
- System Book Balance: (auto-calculated from ledger)
|
||||
- [ ] **Step 4**: Add outstanding items:
|
||||
- Outstanding checks: [{"amount": 5000, "check_number": "CHK-001"}]
|
||||
- Deposits in transit: [{"amount": 10000, "date": "2025-11-29"}]
|
||||
- Bank charges: [{"amount": 50, "description": "Service fee"}]
|
||||
- [ ] **Step 5**: Submit form
|
||||
- [ ] **Step 6**: Verify:
|
||||
- [ ] Reconciliation created
|
||||
- [ ] Adjusted balance calculated correctly
|
||||
- [ ] Discrepancy detected if amounts don't match
|
||||
- [ ] Status based on discrepancy
|
||||
|
||||
#### Review Bank Reconciliation (Accountant)
|
||||
- [ ] **Step 1**: Login as `accountant@test.com`
|
||||
- [ ] **Step 2**: Navigate to pending reconciliation
|
||||
- [ ] **Step 3**: Review outstanding items
|
||||
- [ ] **Step 4**: Click "Review"
|
||||
- [ ] **Step 5**: Verify reviewed_at timestamp set
|
||||
|
||||
#### Approve Bank Reconciliation (Chair)
|
||||
- [ ] **Step 1**: Login as `chair@test.com`
|
||||
- [ ] **Step 2**: Navigate to reviewed reconciliation
|
||||
- [ ] **Step 3**: Click "Approve"
|
||||
- [ ] **Step 4**: Verify:
|
||||
- [ ] Status changed to "completed" or "discrepancy"
|
||||
- [ ] Approved_at timestamp set
|
||||
|
||||
---
|
||||
|
||||
## 2. Automated Tests
|
||||
|
||||
### 2.1 Feature Tests
|
||||
|
||||
Create file: `tests/Feature/FinancialWorkflowTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Models\User;
|
||||
use App\Models\FinanceDocument;
|
||||
use App\Models\PaymentOrder;
|
||||
use App\Models\CashierLedgerEntry;
|
||||
use App\Models\BankReconciliation;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class FinancialWorkflowTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed(FinancialWorkflowPermissionsSeeder::class);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function small_amount_workflow_completes_without_chair()
|
||||
{
|
||||
// Create users
|
||||
$cashier = User::factory()->create();
|
||||
$accountant = User::factory()->create();
|
||||
$requester = User::factory()->create();
|
||||
|
||||
$cashier->assignRole('finance_cashier');
|
||||
$accountant->assignRole('finance_accountant');
|
||||
$requester->assignRole('finance_requester');
|
||||
|
||||
// Step 1: Requester submits
|
||||
$document = FinanceDocument::create([
|
||||
'submitted_by_user_id' => $requester->id,
|
||||
'title' => 'Small Expense',
|
||||
'amount' => 3000,
|
||||
'request_type' => 'expense_reimbursement',
|
||||
'status' => 'pending',
|
||||
'submitted_at' => now(),
|
||||
]);
|
||||
|
||||
$document->amount_tier = $document->determineAmountTier();
|
||||
$document->save();
|
||||
|
||||
$this->assertEquals('small', $document->amount_tier);
|
||||
|
||||
// Step 2: Cashier approves
|
||||
$this->actingAs($cashier)
|
||||
->post(route('admin.finance.approve', $document))
|
||||
->assertRedirect();
|
||||
|
||||
$document->refresh();
|
||||
$this->assertEquals('approved_cashier', $document->status);
|
||||
|
||||
// Step 3: Accountant approves
|
||||
$this->actingAs($accountant)
|
||||
->post(route('admin.finance.approve', $document))
|
||||
->assertRedirect();
|
||||
|
||||
$document->refresh();
|
||||
$this->assertEquals('approved_accountant', $document->status);
|
||||
$this->assertTrue($document->isApprovalStageComplete());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function medium_amount_requires_chair_approval()
|
||||
{
|
||||
// Similar test for medium amount...
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function accountant_can_create_payment_order()
|
||||
{
|
||||
// Test payment order creation...
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cashier_can_verify_payment_order()
|
||||
{
|
||||
// Test payment verification...
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cashier_can_execute_payment()
|
||||
{
|
||||
// Test payment execution...
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cashier_ledger_calculates_balance_correctly()
|
||||
{
|
||||
// Test balance calculation...
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function bank_reconciliation_detects_discrepancy()
|
||||
{
|
||||
// Test discrepancy detection...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Unit Tests
|
||||
|
||||
Create file: `tests/Unit/PaymentOrderTest.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Models\PaymentOrder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
class PaymentOrderTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/** @test */
|
||||
public function generates_unique_payment_order_number()
|
||||
{
|
||||
$number1 = PaymentOrder::generatePaymentOrderNumber();
|
||||
$number2 = PaymentOrder::generatePaymentOrderNumber();
|
||||
|
||||
$this->assertStringStartsWith('PO-', $number1);
|
||||
$this->assertStringStartsWith('PO-', $number2);
|
||||
$this->assertNotEquals($number1, $number2);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_be_verified_when_pending()
|
||||
{
|
||||
$order = PaymentOrder::factory()->create([
|
||||
'status' => 'pending_verification',
|
||||
'verification_status' => 'pending',
|
||||
]);
|
||||
|
||||
$this->assertTrue($order->canBeVerifiedByCashier());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cannot_be_verified_when_already_verified()
|
||||
{
|
||||
$order = PaymentOrder::factory()->create([
|
||||
'status' => 'verified',
|
||||
'verification_status' => 'approved',
|
||||
]);
|
||||
|
||||
$this->assertFalse($order->canBeVerifiedByCashier());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_be_executed_when_verified_and_approved()
|
||||
{
|
||||
$order = PaymentOrder::factory()->create([
|
||||
'status' => 'verified',
|
||||
'verification_status' => 'approved',
|
||||
'execution_status' => 'pending',
|
||||
]);
|
||||
|
||||
$this->assertTrue($order->canBeExecuted());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Integration Tests
|
||||
|
||||
```php
|
||||
/** @test */
|
||||
public function complete_workflow_from_document_to_reconciliation()
|
||||
{
|
||||
// 1. Create and approve document
|
||||
// 2. Create payment order
|
||||
// 3. Verify payment order
|
||||
// 4. Execute payment
|
||||
// 5. Record in cashier ledger
|
||||
// 6. Create accounting transaction
|
||||
// 7. Perform bank reconciliation
|
||||
// Assert all steps completed successfully
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Permission Tests
|
||||
|
||||
### Test Permission Enforcement
|
||||
```php
|
||||
/** @test */
|
||||
public function non_cashier_cannot_verify_payment()
|
||||
{
|
||||
$accountant = User::factory()->create();
|
||||
$accountant->assignRole('finance_accountant');
|
||||
|
||||
$order = PaymentOrder::factory()->create([
|
||||
'status' => 'pending_verification',
|
||||
]);
|
||||
|
||||
$this->actingAs($accountant)
|
||||
->post(route('admin.payment-orders.verify', $order))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function non_accountant_cannot_create_payment_order()
|
||||
{
|
||||
$cashier = User::factory()->create();
|
||||
$cashier->assignRole('finance_cashier');
|
||||
|
||||
$document = FinanceDocument::factory()->create([
|
||||
'status' => 'approved_accountant',
|
||||
]);
|
||||
|
||||
$this->actingAs($cashier)
|
||||
->post(route('admin.payment-orders.store', $document), [
|
||||
'payee_name' => 'Test',
|
||||
'payment_amount' => 1000,
|
||||
'payment_method' => 'cash',
|
||||
])
|
||||
->assertForbidden();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Edge Cases and Error Handling
|
||||
|
||||
### Test Cases
|
||||
- [ ] Cannot approve already approved document
|
||||
- [ ] Cannot verify already verified payment order
|
||||
- [ ] Cannot execute payment without verification
|
||||
- [ ] Cannot create payment order for unapproved document
|
||||
- [ ] Balance calculation with negative balance
|
||||
- [ ] Bank reconciliation with exact match (no discrepancy)
|
||||
- [ ] Bank reconciliation with large discrepancy
|
||||
- [ ] File upload size limits
|
||||
- [ ] Invalid file types
|
||||
- [ ] Missing required fields
|
||||
- [ ] Concurrent access to same document
|
||||
- [ ] Cancelling executed payment order (should fail)
|
||||
|
||||
---
|
||||
|
||||
## 5. Performance Tests
|
||||
|
||||
### Load Testing Checklist
|
||||
- [ ] Create 1,000 finance documents
|
||||
- [ ] Create 1,000 payment orders
|
||||
- [ ] Create 10,000 ledger entries
|
||||
- [ ] Test pagination performance
|
||||
- [ ] Test search/filter performance
|
||||
- [ ] Test balance calculation with large datasets
|
||||
- [ ] Test bank reconciliation with many outstanding items
|
||||
|
||||
---
|
||||
|
||||
## 6. Security Tests
|
||||
|
||||
### Security Checklist
|
||||
- [ ] SQL injection in search filters
|
||||
- [ ] XSS in notes fields
|
||||
- [ ] CSRF token validation
|
||||
- [ ] File upload security (malicious files)
|
||||
- [ ] Path traversal in file downloads
|
||||
- [ ] Authorization bypass attempts
|
||||
- [ ] Rate limiting on sensitive operations
|
||||
|
||||
---
|
||||
|
||||
## 7. User Acceptance Testing (UAT)
|
||||
|
||||
### UAT Checklist
|
||||
- [ ] UI is intuitive and easy to navigate
|
||||
- [ ] Error messages are clear and helpful
|
||||
- [ ] Success messages provide adequate feedback
|
||||
- [ ] Forms validate input properly
|
||||
- [ ] Tables display data correctly
|
||||
- [ ] Pagination works smoothly
|
||||
- [ ] Filters work as expected
|
||||
- [ ] File uploads work reliably
|
||||
- [ ] Downloads work correctly
|
||||
- [ ] Email notifications are received
|
||||
- [ ] Mobile responsiveness (if applicable)
|
||||
|
||||
---
|
||||
|
||||
## 8. Regression Testing
|
||||
|
||||
### After Each Change
|
||||
- [ ] Run all automated tests
|
||||
- [ ] Test complete workflow manually
|
||||
- [ ] Verify no existing functionality broken
|
||||
- [ ] Check database integrity
|
||||
- [ ] Verify audit logs still working
|
||||
|
||||
---
|
||||
|
||||
## Test Execution Log
|
||||
|
||||
| Date | Tester | Test Section | Status | Notes |
|
||||
|------|--------|--------------|--------|-------|
|
||||
| | | | | |
|
||||
|
||||
---
|
||||
|
||||
## Bugs/Issues Tracker
|
||||
|
||||
| ID | Priority | Description | Steps to Reproduce | Status | Fixed By |
|
||||
|----|----------|-------------|-------------------|--------|----------|
|
||||
| | | | | | |
|
||||
|
||||
---
|
||||
|
||||
## Sign-off
|
||||
|
||||
- [ ] All manual tests passed
|
||||
- [ ] All automated tests passing
|
||||
- [ ] Performance acceptable
|
||||
- [ ] Security verified
|
||||
- [ ] UAT completed
|
||||
- [ ] Documentation complete
|
||||
|
||||
**Tested by**: ________________
|
||||
**Date**: ________________
|
||||
**Approved by**: ________________
|
||||
**Date**: ________________
|
||||
55
tests/Feature/Auth/AuthenticationTest.php
Normal file
55
tests/Feature/Auth/AuthenticationTest.php
Normal 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('/');
|
||||
}
|
||||
}
|
||||
65
tests/Feature/Auth/EmailVerificationTest.php
Normal file
65
tests/Feature/Auth/EmailVerificationTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
44
tests/Feature/Auth/PasswordConfirmationTest.php
Normal file
44
tests/Feature/Auth/PasswordConfirmationTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
73
tests/Feature/Auth/PasswordResetTest.php
Normal file
73
tests/Feature/Auth/PasswordResetTest.php
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
51
tests/Feature/Auth/PasswordUpdateTest.php
Normal file
51
tests/Feature/Auth/PasswordUpdateTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
32
tests/Feature/Auth/RegistrationTest.php
Normal file
32
tests/Feature/Auth/RegistrationTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
245
tests/Feature/AuthorizationTest.php
Normal file
245
tests/Feature/AuthorizationTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
368
tests/Feature/BankReconciliationWorkflowTest.php
Normal file
368
tests/Feature/BankReconciliationWorkflowTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
308
tests/Feature/CashierLedgerWorkflowTest.php
Normal file
308
tests/Feature/CashierLedgerWorkflowTest.php
Normal 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
298
tests/Feature/EmailTest.php
Normal 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('>');
|
||||
}
|
||||
}
|
||||
19
tests/Feature/ExampleTest.php
Normal file
19
tests/Feature/ExampleTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
352
tests/Feature/FinanceDocumentWorkflowTest.php
Normal file
352
tests/Feature/FinanceDocumentWorkflowTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
261
tests/Feature/MemberRegistrationTest.php
Normal file
261
tests/Feature/MemberRegistrationTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
311
tests/Feature/PaymentOrderWorkflowTest.php
Normal file
311
tests/Feature/PaymentOrderWorkflowTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
488
tests/Feature/PaymentVerificationTest.php
Normal file
488
tests/Feature/PaymentVerificationTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
99
tests/Feature/ProfileTest.php
Normal file
99
tests/Feature/ProfileTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
10
tests/TestCase.php
Normal file
10
tests/TestCase.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
use CreatesApplication;
|
||||
}
|
||||
302
tests/Unit/BankReconciliationTest.php
Normal file
302
tests/Unit/BankReconciliationTest.php
Normal file
@@ -0,0 +1,302 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\BankReconciliation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Bank Reconciliation Model Unit Tests
|
||||
*
|
||||
* Tests calculation and validation methods in BankReconciliation model
|
||||
*/
|
||||
class BankReconciliationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/** @test */
|
||||
public function it_calculates_adjusted_balance_with_all_items()
|
||||
{
|
||||
$reconciliation = new BankReconciliation([
|
||||
'system_book_balance' => 100000,
|
||||
'outstanding_checks' => [
|
||||
['check_number' => 'CHK001', 'amount' => 3000],
|
||||
['check_number' => 'CHK002', 'amount' => 2000],
|
||||
],
|
||||
'deposits_in_transit' => [
|
||||
['date' => '2024-01-15', 'amount' => 5000],
|
||||
['date' => '2024-01-16', 'amount' => 3000],
|
||||
],
|
||||
'bank_charges' => [
|
||||
['amount' => 500],
|
||||
['amount' => 200],
|
||||
],
|
||||
]);
|
||||
|
||||
// Adjusted = 100000 + (5000 + 3000) - (3000 + 2000) - (500 + 200) = 102300
|
||||
$adjusted = $reconciliation->calculateAdjustedBalance();
|
||||
$this->assertEquals(102300, $adjusted);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_calculates_adjusted_balance_with_no_items()
|
||||
{
|
||||
$reconciliation = new BankReconciliation([
|
||||
'system_book_balance' => 50000,
|
||||
'outstanding_checks' => null,
|
||||
'deposits_in_transit' => null,
|
||||
'bank_charges' => null,
|
||||
]);
|
||||
|
||||
$adjusted = $reconciliation->calculateAdjustedBalance();
|
||||
$this->assertEquals(50000, $adjusted);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_calculates_adjusted_balance_with_empty_arrays()
|
||||
{
|
||||
$reconciliation = new BankReconciliation([
|
||||
'system_book_balance' => 50000,
|
||||
'outstanding_checks' => [],
|
||||
'deposits_in_transit' => [],
|
||||
'bank_charges' => [],
|
||||
]);
|
||||
|
||||
$adjusted = $reconciliation->calculateAdjustedBalance();
|
||||
$this->assertEquals(50000, $adjusted);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_calculates_discrepancy_correctly()
|
||||
{
|
||||
$reconciliation = new BankReconciliation([
|
||||
'bank_statement_balance' => 100000,
|
||||
'system_book_balance' => 95000,
|
||||
'outstanding_checks' => [
|
||||
['amount' => 3000],
|
||||
],
|
||||
'deposits_in_transit' => [
|
||||
['amount' => 5000],
|
||||
],
|
||||
'bank_charges' => [
|
||||
['amount' => 500],
|
||||
],
|
||||
]);
|
||||
|
||||
// Adjusted = 95000 + 5000 - 3000 - 500 = 96500
|
||||
// Discrepancy = |100000 - 96500| = 3500
|
||||
$discrepancy = $reconciliation->calculateDiscrepancy();
|
||||
$this->assertEquals(3500, $discrepancy);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_detects_discrepancy_above_tolerance()
|
||||
{
|
||||
$reconciliation = new BankReconciliation([
|
||||
'bank_statement_balance' => 100000,
|
||||
'system_book_balance' => 95000,
|
||||
'outstanding_checks' => [],
|
||||
'deposits_in_transit' => [],
|
||||
'bank_charges' => [],
|
||||
]);
|
||||
|
||||
// Discrepancy = 5000, which is > 0.01
|
||||
$this->assertTrue($reconciliation->hasDiscrepancy());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_allows_small_discrepancy_within_tolerance()
|
||||
{
|
||||
$reconciliation = new BankReconciliation([
|
||||
'bank_statement_balance' => 100000.00,
|
||||
'system_book_balance' => 100000.00,
|
||||
'outstanding_checks' => [],
|
||||
'deposits_in_transit' => [],
|
||||
'bank_charges' => [],
|
||||
]);
|
||||
|
||||
// Discrepancy = 0, which is <= 0.01
|
||||
$this->assertFalse($reconciliation->hasDiscrepancy());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_generates_outstanding_items_summary_correctly()
|
||||
{
|
||||
$reconciliation = new BankReconciliation([
|
||||
'outstanding_checks' => [
|
||||
['check_number' => 'CHK001', 'amount' => 3000],
|
||||
['check_number' => 'CHK002', 'amount' => 2000],
|
||||
['check_number' => 'CHK003', 'amount' => 1500],
|
||||
],
|
||||
'deposits_in_transit' => [
|
||||
['date' => '2024-01-15', 'amount' => 5000],
|
||||
['date' => '2024-01-16', 'amount' => 3000],
|
||||
],
|
||||
'bank_charges' => [
|
||||
['amount' => 500],
|
||||
['amount' => 200],
|
||||
['amount' => 100],
|
||||
],
|
||||
]);
|
||||
|
||||
$summary = $reconciliation->getOutstandingItemsSummary();
|
||||
|
||||
$this->assertEquals(6500, $summary['total_outstanding_checks']);
|
||||
$this->assertEquals(3, $summary['outstanding_checks_count']);
|
||||
$this->assertEquals(8000, $summary['total_deposits_in_transit']);
|
||||
$this->assertEquals(2, $summary['deposits_in_transit_count']);
|
||||
$this->assertEquals(800, $summary['total_bank_charges']);
|
||||
$this->assertEquals(3, $summary['bank_charges_count']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_null_outstanding_items_in_summary()
|
||||
{
|
||||
$reconciliation = new BankReconciliation([
|
||||
'outstanding_checks' => null,
|
||||
'deposits_in_transit' => null,
|
||||
'bank_charges' => null,
|
||||
]);
|
||||
|
||||
$summary = $reconciliation->getOutstandingItemsSummary();
|
||||
|
||||
$this->assertEquals(0, $summary['total_outstanding_checks']);
|
||||
$this->assertEquals(0, $summary['outstanding_checks_count']);
|
||||
$this->assertEquals(0, $summary['total_deposits_in_transit']);
|
||||
$this->assertEquals(0, $summary['deposits_in_transit_count']);
|
||||
$this->assertEquals(0, $summary['total_bank_charges']);
|
||||
$this->assertEquals(0, $summary['bank_charges_count']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_be_reviewed_when_pending()
|
||||
{
|
||||
$reconciliation = new BankReconciliation([
|
||||
'reconciliation_status' => 'pending',
|
||||
'reviewed_at' => null,
|
||||
]);
|
||||
|
||||
$this->assertTrue($reconciliation->canBeReviewed());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_cannot_be_reviewed_when_already_reviewed()
|
||||
{
|
||||
$reconciliation = new BankReconciliation([
|
||||
'reconciliation_status' => 'pending',
|
||||
'reviewed_at' => now(),
|
||||
]);
|
||||
|
||||
$this->assertFalse($reconciliation->canBeReviewed());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_be_approved_when_reviewed()
|
||||
{
|
||||
$reconciliation = new BankReconciliation([
|
||||
'reconciliation_status' => 'pending',
|
||||
'reviewed_at' => now(),
|
||||
'approved_at' => null,
|
||||
]);
|
||||
|
||||
$this->assertTrue($reconciliation->canBeApproved());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_cannot_be_approved_when_not_reviewed()
|
||||
{
|
||||
$reconciliation = new BankReconciliation([
|
||||
'reconciliation_status' => 'pending',
|
||||
'reviewed_at' => null,
|
||||
'approved_at' => null,
|
||||
]);
|
||||
|
||||
$this->assertFalse($reconciliation->canBeApproved());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_cannot_be_approved_when_already_approved()
|
||||
{
|
||||
$reconciliation = new BankReconciliation([
|
||||
'reconciliation_status' => 'completed',
|
||||
'reviewed_at' => now(),
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
|
||||
$this->assertFalse($reconciliation->canBeApproved());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_detects_pending_status()
|
||||
{
|
||||
$pending = new BankReconciliation(['reconciliation_status' => 'pending']);
|
||||
$this->assertTrue($pending->isPending());
|
||||
|
||||
$completed = new BankReconciliation(['reconciliation_status' => 'completed']);
|
||||
$this->assertFalse($completed->isPending());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_detects_completed_status()
|
||||
{
|
||||
$completed = new BankReconciliation(['reconciliation_status' => 'completed']);
|
||||
$this->assertTrue($completed->isCompleted());
|
||||
|
||||
$pending = new BankReconciliation(['reconciliation_status' => 'pending']);
|
||||
$this->assertFalse($pending->isCompleted());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_detects_unresolved_discrepancy()
|
||||
{
|
||||
$withDiscrepancy = new BankReconciliation([
|
||||
'reconciliation_status' => 'discrepancy',
|
||||
]);
|
||||
$this->assertTrue($withDiscrepancy->hasUnresolvedDiscrepancy());
|
||||
|
||||
$completed = new BankReconciliation([
|
||||
'reconciliation_status' => 'completed',
|
||||
]);
|
||||
$this->assertFalse($completed->hasUnresolvedDiscrepancy());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function 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 it_handles_missing_amounts_in_outstanding_items()
|
||||
{
|
||||
$reconciliation = new BankReconciliation([
|
||||
'system_book_balance' => 100000,
|
||||
'outstanding_checks' => [
|
||||
['check_number' => 'CHK001'], // Missing amount
|
||||
['check_number' => 'CHK002', 'amount' => 2000],
|
||||
],
|
||||
'deposits_in_transit' => [
|
||||
['date' => '2024-01-15'], // Missing amount
|
||||
['date' => '2024-01-16', 'amount' => 3000],
|
||||
],
|
||||
'bank_charges' => [
|
||||
['description' => 'Fee'], // Missing amount
|
||||
['amount' => 200],
|
||||
],
|
||||
]);
|
||||
|
||||
// Should handle missing amounts gracefully (treat as 0)
|
||||
$adjusted = $reconciliation->calculateAdjustedBalance();
|
||||
// 100000 + 3000 - 2000 - 200 = 100800
|
||||
$this->assertEquals(100800, $adjusted);
|
||||
}
|
||||
}
|
||||
300
tests/Unit/BudgetTest.php
Normal file
300
tests/Unit/BudgetTest.php
Normal file
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Budget;
|
||||
use App\Models\BudgetItem;
|
||||
use App\Models\ChartOfAccount;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BudgetTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->artisan('db:seed', ['--class' => 'RoleSeeder']);
|
||||
$this->artisan('db:seed', ['--class' => 'ChartOfAccountSeeder']);
|
||||
}
|
||||
|
||||
public function test_budget_belongs_to_created_by_user(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$budget = Budget::factory()->create(['created_by_user_id' => $user->id]);
|
||||
|
||||
$this->assertInstanceOf(User::class, $budget->createdBy);
|
||||
$this->assertEquals($user->id, $budget->createdBy->id);
|
||||
}
|
||||
|
||||
public function test_budget_belongs_to_approved_by_user(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$budget = Budget::factory()->create(['approved_by_user_id' => $user->id]);
|
||||
|
||||
$this->assertInstanceOf(User::class, $budget->approvedBy);
|
||||
$this->assertEquals($user->id, $budget->approvedBy->id);
|
||||
}
|
||||
|
||||
public function test_budget_has_many_budget_items(): void
|
||||
{
|
||||
$budget = Budget::factory()->create();
|
||||
$account1 = ChartOfAccount::first();
|
||||
$account2 = ChartOfAccount::skip(1)->first();
|
||||
|
||||
$item1 = BudgetItem::factory()->create([
|
||||
'budget_id' => $budget->id,
|
||||
'chart_of_account_id' => $account1->id,
|
||||
]);
|
||||
$item2 = BudgetItem::factory()->create([
|
||||
'budget_id' => $budget->id,
|
||||
'chart_of_account_id' => $account2->id,
|
||||
]);
|
||||
|
||||
$this->assertCount(2, $budget->budgetItems);
|
||||
}
|
||||
|
||||
public function test_is_draft_returns_true_when_status_is_draft(): void
|
||||
{
|
||||
$budget = Budget::factory()->create(['status' => Budget::STATUS_DRAFT]);
|
||||
$this->assertTrue($budget->isDraft());
|
||||
}
|
||||
|
||||
public function test_is_approved_returns_true_when_status_is_approved(): void
|
||||
{
|
||||
$budget = Budget::factory()->create(['status' => Budget::STATUS_APPROVED]);
|
||||
$this->assertTrue($budget->isApproved());
|
||||
}
|
||||
|
||||
public function test_is_active_returns_true_when_status_is_active(): void
|
||||
{
|
||||
$budget = Budget::factory()->create(['status' => Budget::STATUS_ACTIVE]);
|
||||
$this->assertTrue($budget->isActive());
|
||||
}
|
||||
|
||||
public function test_is_closed_returns_true_when_status_is_closed(): void
|
||||
{
|
||||
$budget = Budget::factory()->create(['status' => Budget::STATUS_CLOSED]);
|
||||
$this->assertTrue($budget->isClosed());
|
||||
}
|
||||
|
||||
public function test_can_be_edited_validates_correctly(): void
|
||||
{
|
||||
$draftBudget = Budget::factory()->create(['status' => Budget::STATUS_DRAFT]);
|
||||
$this->assertTrue($draftBudget->canBeEdited());
|
||||
|
||||
$submittedBudget = Budget::factory()->create(['status' => Budget::STATUS_SUBMITTED]);
|
||||
$this->assertTrue($submittedBudget->canBeEdited());
|
||||
|
||||
$activeBudget = Budget::factory()->create(['status' => Budget::STATUS_ACTIVE]);
|
||||
$this->assertFalse($activeBudget->canBeEdited());
|
||||
|
||||
$closedBudget = Budget::factory()->create(['status' => Budget::STATUS_CLOSED]);
|
||||
$this->assertFalse($closedBudget->canBeEdited());
|
||||
}
|
||||
|
||||
public function test_can_be_approved_validates_correctly(): void
|
||||
{
|
||||
$submittedBudget = Budget::factory()->create(['status' => Budget::STATUS_SUBMITTED]);
|
||||
$this->assertTrue($submittedBudget->canBeApproved());
|
||||
|
||||
$draftBudget = Budget::factory()->create(['status' => Budget::STATUS_DRAFT]);
|
||||
$this->assertFalse($draftBudget->canBeApproved());
|
||||
|
||||
$approvedBudget = Budget::factory()->create(['status' => Budget::STATUS_APPROVED]);
|
||||
$this->assertFalse($approvedBudget->canBeApproved());
|
||||
}
|
||||
|
||||
public function test_total_budgeted_income_calculation(): void
|
||||
{
|
||||
$budget = Budget::factory()->create();
|
||||
$incomeAccount = ChartOfAccount::where('account_type', ChartOfAccount::TYPE_INCOME)->first();
|
||||
|
||||
if ($incomeAccount) {
|
||||
BudgetItem::factory()->create([
|
||||
'budget_id' => $budget->id,
|
||||
'chart_of_account_id' => $incomeAccount->id,
|
||||
'budgeted_amount' => 10000,
|
||||
]);
|
||||
BudgetItem::factory()->create([
|
||||
'budget_id' => $budget->id,
|
||||
'chart_of_account_id' => $incomeAccount->id,
|
||||
'budgeted_amount' => 5000,
|
||||
]);
|
||||
|
||||
$this->assertEquals(15000, $budget->total_budgeted_income);
|
||||
} else {
|
||||
$this->assertTrue(true, 'No income account available for test');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_total_budgeted_expense_calculation(): void
|
||||
{
|
||||
$budget = Budget::factory()->create();
|
||||
$expenseAccount = ChartOfAccount::where('account_type', ChartOfAccount::TYPE_EXPENSE)->first();
|
||||
|
||||
if ($expenseAccount) {
|
||||
BudgetItem::factory()->create([
|
||||
'budget_id' => $budget->id,
|
||||
'chart_of_account_id' => $expenseAccount->id,
|
||||
'budgeted_amount' => 8000,
|
||||
]);
|
||||
BudgetItem::factory()->create([
|
||||
'budget_id' => $budget->id,
|
||||
'chart_of_account_id' => $expenseAccount->id,
|
||||
'budgeted_amount' => 3000,
|
||||
]);
|
||||
|
||||
$this->assertEquals(11000, $budget->total_budgeted_expense);
|
||||
} else {
|
||||
$this->assertTrue(true, 'No expense account available for test');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_total_actual_income_calculation(): void
|
||||
{
|
||||
$budget = Budget::factory()->create();
|
||||
$incomeAccount = ChartOfAccount::where('account_type', ChartOfAccount::TYPE_INCOME)->first();
|
||||
|
||||
if ($incomeAccount) {
|
||||
BudgetItem::factory()->create([
|
||||
'budget_id' => $budget->id,
|
||||
'chart_of_account_id' => $incomeAccount->id,
|
||||
'budgeted_amount' => 10000,
|
||||
'actual_amount' => 12000,
|
||||
]);
|
||||
BudgetItem::factory()->create([
|
||||
'budget_id' => $budget->id,
|
||||
'chart_of_account_id' => $incomeAccount->id,
|
||||
'budgeted_amount' => 5000,
|
||||
'actual_amount' => 4500,
|
||||
]);
|
||||
|
||||
$this->assertEquals(16500, $budget->total_actual_income);
|
||||
} else {
|
||||
$this->assertTrue(true, 'No income account available for test');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_total_actual_expense_calculation(): void
|
||||
{
|
||||
$budget = Budget::factory()->create();
|
||||
$expenseAccount = ChartOfAccount::where('account_type', ChartOfAccount::TYPE_EXPENSE)->first();
|
||||
|
||||
if ($expenseAccount) {
|
||||
BudgetItem::factory()->create([
|
||||
'budget_id' => $budget->id,
|
||||
'chart_of_account_id' => $expenseAccount->id,
|
||||
'budgeted_amount' => 8000,
|
||||
'actual_amount' => 7500,
|
||||
]);
|
||||
BudgetItem::factory()->create([
|
||||
'budget_id' => $budget->id,
|
||||
'chart_of_account_id' => $expenseAccount->id,
|
||||
'budgeted_amount' => 3000,
|
||||
'actual_amount' => 3200,
|
||||
]);
|
||||
|
||||
$this->assertEquals(10700, $budget->total_actual_expense);
|
||||
} else {
|
||||
$this->assertTrue(true, 'No expense account available for test');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_budget_item_variance_calculation(): void
|
||||
{
|
||||
$account = ChartOfAccount::first();
|
||||
$budgetItem = BudgetItem::factory()->create([
|
||||
'chart_of_account_id' => $account->id,
|
||||
'budgeted_amount' => 10000,
|
||||
'actual_amount' => 8500,
|
||||
]);
|
||||
|
||||
$this->assertEquals(-1500, $budgetItem->variance);
|
||||
}
|
||||
|
||||
public function test_budget_item_variance_percentage_calculation(): void
|
||||
{
|
||||
$account = ChartOfAccount::first();
|
||||
$budgetItem = BudgetItem::factory()->create([
|
||||
'chart_of_account_id' => $account->id,
|
||||
'budgeted_amount' => 10000,
|
||||
'actual_amount' => 8500,
|
||||
]);
|
||||
|
||||
$this->assertEquals(-15.0, $budgetItem->variance_percentage);
|
||||
}
|
||||
|
||||
public function test_budget_item_remaining_budget_calculation(): void
|
||||
{
|
||||
$account = ChartOfAccount::first();
|
||||
$budgetItem = BudgetItem::factory()->create([
|
||||
'chart_of_account_id' => $account->id,
|
||||
'budgeted_amount' => 10000,
|
||||
'actual_amount' => 6000,
|
||||
]);
|
||||
|
||||
$this->assertEquals(4000, $budgetItem->remaining_budget);
|
||||
}
|
||||
|
||||
public function test_budget_item_is_over_budget_detection(): void
|
||||
{
|
||||
$account = ChartOfAccount::first();
|
||||
|
||||
$overBudgetItem = BudgetItem::factory()->create([
|
||||
'chart_of_account_id' => $account->id,
|
||||
'budgeted_amount' => 10000,
|
||||
'actual_amount' => 12000,
|
||||
]);
|
||||
$this->assertTrue($overBudgetItem->isOverBudget());
|
||||
|
||||
$underBudgetItem = BudgetItem::factory()->create([
|
||||
'chart_of_account_id' => $account->id,
|
||||
'budgeted_amount' => 10000,
|
||||
'actual_amount' => 8000,
|
||||
]);
|
||||
$this->assertFalse($underBudgetItem->isOverBudget());
|
||||
}
|
||||
|
||||
public function test_budget_item_utilization_percentage_calculation(): void
|
||||
{
|
||||
$account = ChartOfAccount::first();
|
||||
$budgetItem = BudgetItem::factory()->create([
|
||||
'chart_of_account_id' => $account->id,
|
||||
'budgeted_amount' => 10000,
|
||||
'actual_amount' => 7500,
|
||||
]);
|
||||
|
||||
$this->assertEquals(75.0, $budgetItem->utilization_percentage);
|
||||
}
|
||||
|
||||
public function test_budget_workflow_sequence(): void
|
||||
{
|
||||
$budget = Budget::factory()->create(['status' => Budget::STATUS_DRAFT]);
|
||||
|
||||
// Draft can be edited
|
||||
$this->assertTrue($budget->canBeEdited());
|
||||
$this->assertFalse($budget->canBeApproved());
|
||||
|
||||
// Submitted can be edited and approved
|
||||
$budget->status = Budget::STATUS_SUBMITTED;
|
||||
$this->assertTrue($budget->canBeEdited());
|
||||
$this->assertTrue($budget->canBeApproved());
|
||||
|
||||
// Approved cannot be edited
|
||||
$budget->status = Budget::STATUS_APPROVED;
|
||||
$this->assertFalse($budget->canBeEdited());
|
||||
$this->assertFalse($budget->canBeApproved());
|
||||
|
||||
// Active cannot be edited
|
||||
$budget->status = Budget::STATUS_ACTIVE;
|
||||
$this->assertFalse($budget->canBeEdited());
|
||||
|
||||
// Closed cannot be edited
|
||||
$budget->status = Budget::STATUS_CLOSED;
|
||||
$this->assertFalse($budget->canBeEdited());
|
||||
}
|
||||
}
|
||||
16
tests/Unit/ExampleTest.php
Normal file
16
tests/Unit/ExampleTest.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_that_true_is_true(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
312
tests/Unit/FinanceDocumentTest.php
Normal file
312
tests/Unit/FinanceDocumentTest.php
Normal file
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\FinanceDocument;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Finance Document Model Unit Tests
|
||||
*
|
||||
* Tests business logic methods in FinanceDocument model
|
||||
*/
|
||||
class FinanceDocumentTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/** @test */
|
||||
public function it_determines_small_amount_tier_correctly()
|
||||
{
|
||||
$document = new FinanceDocument(['amount' => 4999]);
|
||||
$this->assertEquals('small', $document->determineAmountTier());
|
||||
|
||||
$document->amount = 3000;
|
||||
$this->assertEquals('small', $document->determineAmountTier());
|
||||
|
||||
$document->amount = 1;
|
||||
$this->assertEquals('small', $document->determineAmountTier());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_determines_medium_amount_tier_correctly()
|
||||
{
|
||||
$document = new FinanceDocument(['amount' => 5000]);
|
||||
$this->assertEquals('medium', $document->determineAmountTier());
|
||||
|
||||
$document->amount = 25000;
|
||||
$this->assertEquals('medium', $document->determineAmountTier());
|
||||
|
||||
$document->amount = 50000;
|
||||
$this->assertEquals('medium', $document->determineAmountTier());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_determines_large_amount_tier_correctly()
|
||||
{
|
||||
$document = new FinanceDocument(['amount' => 50001]);
|
||||
$this->assertEquals('large', $document->determineAmountTier());
|
||||
|
||||
$document->amount = 100000;
|
||||
$this->assertEquals('large', $document->determineAmountTier());
|
||||
|
||||
$document->amount = 1000000;
|
||||
$this->assertEquals('large', $document->determineAmountTier());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function small_amount_does_not_need_board_meeting()
|
||||
{
|
||||
$document = new FinanceDocument(['amount' => 4999]);
|
||||
$this->assertFalse($document->needsBoardMeetingApproval());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function medium_amount_does_not_need_board_meeting()
|
||||
{
|
||||
$document = new FinanceDocument(['amount' => 50000]);
|
||||
$this->assertFalse($document->needsBoardMeetingApproval());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function large_amount_needs_board_meeting()
|
||||
{
|
||||
$document = new FinanceDocument(['amount' => 50001]);
|
||||
$this->assertTrue($document->needsBoardMeetingApproval());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function small_amount_approval_stage_is_complete_after_accountant()
|
||||
{
|
||||
$document = new FinanceDocument([
|
||||
'amount' => 3000,
|
||||
'amount_tier' => 'small',
|
||||
'status' => FinanceDocument::STATUS_APPROVED_ACCOUNTANT,
|
||||
]);
|
||||
|
||||
$this->assertTrue($document->isApprovalStageComplete());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function medium_amount_approval_stage_needs_chair()
|
||||
{
|
||||
$document = new FinanceDocument([
|
||||
'amount' => 25000,
|
||||
'amount_tier' => 'medium',
|
||||
'status' => FinanceDocument::STATUS_APPROVED_ACCOUNTANT,
|
||||
]);
|
||||
|
||||
$this->assertFalse($document->isApprovalStageComplete());
|
||||
|
||||
$document->status = FinanceDocument::STATUS_APPROVED_CHAIR;
|
||||
$this->assertTrue($document->isApprovalStageComplete());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function large_amount_approval_stage_needs_chair_and_board()
|
||||
{
|
||||
$document = new FinanceDocument([
|
||||
'amount' => 75000,
|
||||
'amount_tier' => 'large',
|
||||
'status' => FinanceDocument::STATUS_APPROVED_CHAIR,
|
||||
'board_meeting_approved_at' => null,
|
||||
]);
|
||||
|
||||
$this->assertFalse($document->isApprovalStageComplete());
|
||||
|
||||
$document->board_meeting_approved_at = now();
|
||||
$this->assertTrue($document->isApprovalStageComplete());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cashier_cannot_approve_own_submission()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$document = new FinanceDocument([
|
||||
'submitted_by_id' => $user->id,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
$this->assertFalse($document->canBeApprovedByCashier($user));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cashier_can_approve_others_submission()
|
||||
{
|
||||
$submitter = User::factory()->create();
|
||||
$cashier = User::factory()->create();
|
||||
|
||||
$document = new FinanceDocument([
|
||||
'submitted_by_id' => $submitter->id,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
$this->assertTrue($document->canBeApprovedByCashier($cashier));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function accountant_cannot_approve_before_cashier()
|
||||
{
|
||||
$document = new FinanceDocument([
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
$this->assertFalse($document->canBeApprovedByAccountant());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function accountant_can_approve_after_cashier()
|
||||
{
|
||||
$document = new FinanceDocument([
|
||||
'status' => FinanceDocument::STATUS_APPROVED_CASHIER,
|
||||
]);
|
||||
|
||||
$this->assertTrue($document->canBeApprovedByAccountant());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function chair_cannot_approve_before_accountant()
|
||||
{
|
||||
$document = new FinanceDocument([
|
||||
'status' => FinanceDocument::STATUS_APPROVED_CASHIER,
|
||||
'amount_tier' => 'medium',
|
||||
]);
|
||||
|
||||
$this->assertFalse($document->canBeApprovedByChair());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function chair_can_approve_after_accountant_for_medium_amounts()
|
||||
{
|
||||
$document = new FinanceDocument([
|
||||
'status' => FinanceDocument::STATUS_APPROVED_ACCOUNTANT,
|
||||
'amount_tier' => 'medium',
|
||||
]);
|
||||
|
||||
$this->assertTrue($document->canBeApprovedByChair());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function payment_order_can_be_created_after_approval_stage()
|
||||
{
|
||||
$document = new FinanceDocument([
|
||||
'amount' => 3000,
|
||||
'amount_tier' => 'small',
|
||||
'status' => FinanceDocument::STATUS_APPROVED_ACCOUNTANT,
|
||||
]);
|
||||
|
||||
$this->assertTrue($document->canCreatePaymentOrder());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function payment_order_cannot_be_created_before_approval_complete()
|
||||
{
|
||||
$document = new FinanceDocument([
|
||||
'status' => FinanceDocument::STATUS_APPROVED_CASHIER,
|
||||
]);
|
||||
|
||||
$this->assertFalse($document->canCreatePaymentOrder());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function workflow_stages_are_correctly_identified()
|
||||
{
|
||||
$document = new FinanceDocument([
|
||||
'status' => 'pending',
|
||||
'amount_tier' => 'small',
|
||||
]);
|
||||
|
||||
// Stage 1: Approval
|
||||
$this->assertEquals('approval', $document->getCurrentWorkflowStage());
|
||||
|
||||
// Stage 2: Payment
|
||||
$document->status = FinanceDocument::STATUS_APPROVED_ACCOUNTANT;
|
||||
$document->cashier_approved_at = now();
|
||||
$document->accountant_approved_at = now();
|
||||
$document->payment_order_created_at = now();
|
||||
$this->assertEquals('payment', $document->getCurrentWorkflowStage());
|
||||
|
||||
// Stage 3: Recording
|
||||
$document->payment_executed_at = now();
|
||||
$this->assertEquals('payment', $document->getCurrentWorkflowStage());
|
||||
|
||||
$document->cashier_recorded_at = now();
|
||||
$this->assertEquals('recording', $document->getCurrentWorkflowStage());
|
||||
|
||||
// Stage 4: Reconciliation
|
||||
$document->bank_reconciliation_id = 1;
|
||||
$this->assertEquals('completed', $document->getCurrentWorkflowStage());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function payment_completed_check_works()
|
||||
{
|
||||
$document = new FinanceDocument([
|
||||
'payment_order_created_at' => now(),
|
||||
'payment_verified_at' => now(),
|
||||
'payment_executed_at' => null,
|
||||
]);
|
||||
|
||||
$this->assertFalse($document->isPaymentCompleted());
|
||||
|
||||
$document->payment_executed_at = now();
|
||||
$this->assertTrue($document->isPaymentCompleted());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function recording_complete_check_works()
|
||||
{
|
||||
$document = new FinanceDocument([
|
||||
'cashier_recorded_at' => null,
|
||||
]);
|
||||
|
||||
$this->assertFalse($document->isRecordingComplete());
|
||||
|
||||
$document->cashier_recorded_at = now();
|
||||
$this->assertTrue($document->isRecordingComplete());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function reconciled_check_works()
|
||||
{
|
||||
$document = new FinanceDocument([
|
||||
'bank_reconciliation_id' => null,
|
||||
]);
|
||||
|
||||
$this->assertFalse($document->isReconciled());
|
||||
|
||||
$document->bank_reconciliation_id = 1;
|
||||
$this->assertTrue($document->isReconciled());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function request_type_text_is_correct()
|
||||
{
|
||||
$doc1 = new FinanceDocument(['request_type' => 'expense_reimbursement']);
|
||||
$this->assertEquals('費用報銷', $doc1->getRequestTypeText());
|
||||
|
||||
$doc2 = new FinanceDocument(['request_type' => 'advance_payment']);
|
||||
$this->assertEquals('預支款項', $doc2->getRequestTypeText());
|
||||
|
||||
$doc3 = new FinanceDocument(['request_type' => 'purchase_request']);
|
||||
$this->assertEquals('採購申請', $doc3->getRequestTypeText());
|
||||
|
||||
$doc4 = new FinanceDocument(['request_type' => 'petty_cash']);
|
||||
$this->assertEquals('零用金', $doc4->getRequestTypeText());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function amount_tier_text_is_correct()
|
||||
{
|
||||
$small = new FinanceDocument(['amount_tier' => 'small']);
|
||||
$this->assertEquals('小額(< 5000)', $small->getAmountTierText());
|
||||
|
||||
$medium = new FinanceDocument(['amount_tier' => 'medium']);
|
||||
$this->assertEquals('中額(5000-50000)', $medium->getAmountTierText());
|
||||
|
||||
$large = new FinanceDocument(['amount_tier' => 'large']);
|
||||
$this->assertEquals('大額(> 50000)', $large->getAmountTierText());
|
||||
}
|
||||
}
|
||||
328
tests/Unit/IssueTest.php
Normal file
328
tests/Unit/IssueTest.php
Normal file
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Issue;
|
||||
use App\Models\IssueComment;
|
||||
use App\Models\IssueAttachment;
|
||||
use App\Models\IssueLabel;
|
||||
use App\Models\IssueTimeLog;
|
||||
use App\Models\User;
|
||||
use App\Models\Member;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class IssueTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->artisan('db:seed', ['--class' => 'RoleSeeder']);
|
||||
}
|
||||
|
||||
public function test_issue_number_auto_generation(): void
|
||||
{
|
||||
$issue1 = Issue::factory()->create();
|
||||
$issue2 = Issue::factory()->create();
|
||||
|
||||
$this->assertMatchesRegularExpression('/^ISS-\d{4}-\d{3}$/', $issue1->issue_number);
|
||||
$this->assertMatchesRegularExpression('/^ISS-\d{4}-\d{3}$/', $issue2->issue_number);
|
||||
$this->assertNotEquals($issue1->issue_number, $issue2->issue_number);
|
||||
}
|
||||
|
||||
public function test_issue_belongs_to_creator(): void
|
||||
{
|
||||
$creator = User::factory()->create();
|
||||
$issue = Issue::factory()->create(['created_by_user_id' => $creator->id]);
|
||||
|
||||
$this->assertInstanceOf(User::class, $issue->creator);
|
||||
$this->assertEquals($creator->id, $issue->creator->id);
|
||||
}
|
||||
|
||||
public function test_issue_belongs_to_assignee(): void
|
||||
{
|
||||
$assignee = User::factory()->create();
|
||||
$issue = Issue::factory()->create(['assigned_to_user_id' => $assignee->id]);
|
||||
|
||||
$this->assertInstanceOf(User::class, $issue->assignee);
|
||||
$this->assertEquals($assignee->id, $issue->assignee->id);
|
||||
}
|
||||
|
||||
public function test_issue_belongs_to_reviewer(): void
|
||||
{
|
||||
$reviewer = User::factory()->create();
|
||||
$issue = Issue::factory()->create(['reviewer_id' => $reviewer->id]);
|
||||
|
||||
$this->assertInstanceOf(User::class, $issue->reviewer);
|
||||
$this->assertEquals($reviewer->id, $issue->reviewer->id);
|
||||
}
|
||||
|
||||
public function test_issue_has_many_comments(): void
|
||||
{
|
||||
$issue = Issue::factory()->create();
|
||||
$comment1 = IssueComment::factory()->create(['issue_id' => $issue->id]);
|
||||
$comment2 = IssueComment::factory()->create(['issue_id' => $issue->id]);
|
||||
|
||||
$this->assertCount(2, $issue->comments);
|
||||
$this->assertTrue($issue->comments->contains($comment1));
|
||||
}
|
||||
|
||||
public function test_issue_has_many_attachments(): void
|
||||
{
|
||||
$issue = Issue::factory()->create();
|
||||
$attachment1 = IssueAttachment::factory()->create(['issue_id' => $issue->id]);
|
||||
$attachment2 = IssueAttachment::factory()->create(['issue_id' => $issue->id]);
|
||||
|
||||
$this->assertCount(2, $issue->attachments);
|
||||
}
|
||||
|
||||
public function test_issue_has_many_time_logs(): void
|
||||
{
|
||||
$issue = Issue::factory()->create();
|
||||
$log1 = IssueTimeLog::factory()->create(['issue_id' => $issue->id, 'hours' => 2.5]);
|
||||
$log2 = IssueTimeLog::factory()->create(['issue_id' => $issue->id, 'hours' => 3.5]);
|
||||
|
||||
$this->assertCount(2, $issue->timeLogs);
|
||||
}
|
||||
|
||||
public function test_issue_has_many_labels(): void
|
||||
{
|
||||
$issue = Issue::factory()->create();
|
||||
$label1 = IssueLabel::factory()->create();
|
||||
$label2 = IssueLabel::factory()->create();
|
||||
|
||||
$issue->labels()->attach([$label1->id, $label2->id]);
|
||||
|
||||
$this->assertCount(2, $issue->labels);
|
||||
$this->assertTrue($issue->labels->contains($label1));
|
||||
}
|
||||
|
||||
public function test_issue_has_many_watchers(): void
|
||||
{
|
||||
$issue = Issue::factory()->create();
|
||||
$watcher1 = User::factory()->create();
|
||||
$watcher2 = User::factory()->create();
|
||||
|
||||
$issue->watchers()->attach([$watcher1->id, $watcher2->id]);
|
||||
|
||||
$this->assertCount(2, $issue->watchers);
|
||||
}
|
||||
|
||||
public function test_status_check_methods_work(): void
|
||||
{
|
||||
$issue = Issue::factory()->create(['status' => Issue::STATUS_NEW]);
|
||||
$this->assertTrue($issue->isNew());
|
||||
$this->assertFalse($issue->isAssigned());
|
||||
|
||||
$issue->status = Issue::STATUS_ASSIGNED;
|
||||
$this->assertTrue($issue->isAssigned());
|
||||
$this->assertFalse($issue->isNew());
|
||||
|
||||
$issue->status = Issue::STATUS_IN_PROGRESS;
|
||||
$this->assertTrue($issue->isInProgress());
|
||||
|
||||
$issue->status = Issue::STATUS_REVIEW;
|
||||
$this->assertTrue($issue->inReview());
|
||||
|
||||
$issue->status = Issue::STATUS_CLOSED;
|
||||
$this->assertTrue($issue->isClosed());
|
||||
$this->assertFalse($issue->isOpen());
|
||||
}
|
||||
|
||||
public function test_can_be_assigned_validates_correctly(): void
|
||||
{
|
||||
$newIssue = Issue::factory()->create(['status' => Issue::STATUS_NEW]);
|
||||
$this->assertTrue($newIssue->canBeAssigned());
|
||||
|
||||
$closedIssue = Issue::factory()->create(['status' => Issue::STATUS_CLOSED]);
|
||||
$this->assertFalse($closedIssue->canBeAssigned());
|
||||
}
|
||||
|
||||
public function test_can_move_to_in_progress_validates_correctly(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$assignedIssue = Issue::factory()->create([
|
||||
'status' => Issue::STATUS_ASSIGNED,
|
||||
'assigned_to_user_id' => $user->id,
|
||||
]);
|
||||
$this->assertTrue($assignedIssue->canMoveToInProgress());
|
||||
|
||||
$newIssue = Issue::factory()->create(['status' => Issue::STATUS_NEW]);
|
||||
$this->assertFalse($newIssue->canMoveToInProgress());
|
||||
|
||||
$assignedWithoutUser = Issue::factory()->create([
|
||||
'status' => Issue::STATUS_ASSIGNED,
|
||||
'assigned_to_user_id' => null,
|
||||
]);
|
||||
$this->assertFalse($assignedWithoutUser->canMoveToInProgress());
|
||||
}
|
||||
|
||||
public function test_can_move_to_review_validates_correctly(): void
|
||||
{
|
||||
$inProgressIssue = Issue::factory()->create(['status' => Issue::STATUS_IN_PROGRESS]);
|
||||
$this->assertTrue($inProgressIssue->canMoveToReview());
|
||||
|
||||
$newIssue = Issue::factory()->create(['status' => Issue::STATUS_NEW]);
|
||||
$this->assertFalse($newIssue->canMoveToReview());
|
||||
}
|
||||
|
||||
public function test_can_be_closed_validates_correctly(): void
|
||||
{
|
||||
$reviewIssue = Issue::factory()->create(['status' => Issue::STATUS_REVIEW]);
|
||||
$this->assertTrue($reviewIssue->canBeClosed());
|
||||
|
||||
$inProgressIssue = Issue::factory()->create(['status' => Issue::STATUS_IN_PROGRESS]);
|
||||
$this->assertTrue($inProgressIssue->canBeClosed());
|
||||
|
||||
$newIssue = Issue::factory()->create(['status' => Issue::STATUS_NEW]);
|
||||
$this->assertFalse($newIssue->canBeClosed());
|
||||
}
|
||||
|
||||
public function test_can_be_reopened_validates_correctly(): void
|
||||
{
|
||||
$closedIssue = Issue::factory()->create(['status' => Issue::STATUS_CLOSED]);
|
||||
$this->assertTrue($closedIssue->canBeReopened());
|
||||
|
||||
$openIssue = Issue::factory()->create(['status' => Issue::STATUS_NEW]);
|
||||
$this->assertFalse($openIssue->canBeReopened());
|
||||
}
|
||||
|
||||
public function test_progress_percentage_calculation(): void
|
||||
{
|
||||
$newIssue = Issue::factory()->create(['status' => Issue::STATUS_NEW]);
|
||||
$this->assertEquals(0, $newIssue->progress_percentage);
|
||||
|
||||
$assignedIssue = Issue::factory()->create(['status' => Issue::STATUS_ASSIGNED]);
|
||||
$this->assertEquals(25, $assignedIssue->progress_percentage);
|
||||
|
||||
$inProgressIssue = Issue::factory()->create(['status' => Issue::STATUS_IN_PROGRESS]);
|
||||
$this->assertEquals(50, $inProgressIssue->progress_percentage);
|
||||
|
||||
$reviewIssue = Issue::factory()->create(['status' => Issue::STATUS_REVIEW]);
|
||||
$this->assertEquals(75, $reviewIssue->progress_percentage);
|
||||
|
||||
$closedIssue = Issue::factory()->create(['status' => Issue::STATUS_CLOSED]);
|
||||
$this->assertEquals(100, $closedIssue->progress_percentage);
|
||||
}
|
||||
|
||||
public function test_overdue_detection_works(): void
|
||||
{
|
||||
$overdueIssue = Issue::factory()->create([
|
||||
'due_date' => now()->subDays(5),
|
||||
'status' => Issue::STATUS_IN_PROGRESS,
|
||||
]);
|
||||
$this->assertTrue($overdueIssue->is_overdue);
|
||||
|
||||
$upcomingIssue = Issue::factory()->create([
|
||||
'due_date' => now()->addDays(5),
|
||||
'status' => Issue::STATUS_IN_PROGRESS,
|
||||
]);
|
||||
$this->assertFalse($upcomingIssue->is_overdue);
|
||||
|
||||
$closedOverdueIssue = Issue::factory()->create([
|
||||
'due_date' => now()->subDays(5),
|
||||
'status' => Issue::STATUS_CLOSED,
|
||||
]);
|
||||
$this->assertFalse($closedOverdueIssue->is_overdue);
|
||||
}
|
||||
|
||||
public function test_days_until_due_calculation(): void
|
||||
{
|
||||
$issue = Issue::factory()->create([
|
||||
'due_date' => now()->addDays(5),
|
||||
]);
|
||||
$this->assertEquals(5, $issue->days_until_due);
|
||||
|
||||
$overdueIssue = Issue::factory()->create([
|
||||
'due_date' => now()->subDays(3),
|
||||
]);
|
||||
$this->assertEquals(-3, $overdueIssue->days_until_due);
|
||||
}
|
||||
|
||||
public function test_total_time_logged_calculation(): void
|
||||
{
|
||||
$issue = Issue::factory()->create();
|
||||
IssueTimeLog::factory()->create(['issue_id' => $issue->id, 'hours' => 2.5]);
|
||||
IssueTimeLog::factory()->create(['issue_id' => $issue->id, 'hours' => 3.5]);
|
||||
IssueTimeLog::factory()->create(['issue_id' => $issue->id, 'hours' => 1.0]);
|
||||
|
||||
$this->assertEquals(7.0, $issue->total_time_logged);
|
||||
}
|
||||
|
||||
public function test_status_label_returns_correct_text(): void
|
||||
{
|
||||
$issue = Issue::factory()->create(['status' => Issue::STATUS_NEW]);
|
||||
$this->assertEquals('New', $issue->status_label);
|
||||
|
||||
$issue->status = Issue::STATUS_CLOSED;
|
||||
$this->assertEquals('Closed', $issue->status_label);
|
||||
}
|
||||
|
||||
public function test_priority_label_returns_correct_text(): void
|
||||
{
|
||||
$issue = Issue::factory()->create(['priority' => Issue::PRIORITY_LOW]);
|
||||
$this->assertEquals('Low', $issue->priority_label);
|
||||
|
||||
$issue->priority = Issue::PRIORITY_URGENT;
|
||||
$this->assertEquals('Urgent', $issue->priority_label);
|
||||
}
|
||||
|
||||
public function test_badge_color_methods_work(): void
|
||||
{
|
||||
$urgentIssue = Issue::factory()->create(['priority' => Issue::PRIORITY_URGENT]);
|
||||
$this->assertStringContainsString('red', $urgentIssue->priority_badge_color);
|
||||
|
||||
$closedIssue = Issue::factory()->create(['status' => Issue::STATUS_CLOSED]);
|
||||
$this->assertStringContainsString('gray', $closedIssue->status_badge_color);
|
||||
}
|
||||
|
||||
public function test_scopes_work(): void
|
||||
{
|
||||
Issue::factory()->create(['status' => Issue::STATUS_NEW]);
|
||||
Issue::factory()->create(['status' => Issue::STATUS_IN_PROGRESS]);
|
||||
Issue::factory()->create(['status' => Issue::STATUS_CLOSED]);
|
||||
|
||||
$openIssues = Issue::open()->get();
|
||||
$this->assertCount(2, $openIssues);
|
||||
|
||||
$closedIssues = Issue::closed()->get();
|
||||
$this->assertCount(1, $closedIssues);
|
||||
}
|
||||
|
||||
public function test_overdue_scope_works(): void
|
||||
{
|
||||
Issue::factory()->create([
|
||||
'due_date' => now()->subDays(5),
|
||||
'status' => Issue::STATUS_IN_PROGRESS,
|
||||
]);
|
||||
Issue::factory()->create([
|
||||
'due_date' => now()->addDays(5),
|
||||
'status' => Issue::STATUS_IN_PROGRESS,
|
||||
]);
|
||||
|
||||
$overdueIssues = Issue::overdue()->get();
|
||||
$this->assertCount(1, $overdueIssues);
|
||||
}
|
||||
|
||||
public function test_parent_child_relationships_work(): void
|
||||
{
|
||||
$parentIssue = Issue::factory()->create();
|
||||
$childIssue1 = Issue::factory()->create(['parent_issue_id' => $parentIssue->id]);
|
||||
$childIssue2 = Issue::factory()->create(['parent_issue_id' => $parentIssue->id]);
|
||||
|
||||
$this->assertCount(2, $parentIssue->subTasks);
|
||||
$this->assertInstanceOf(Issue::class, $childIssue1->parentIssue);
|
||||
$this->assertEquals($parentIssue->id, $childIssue1->parentIssue->id);
|
||||
}
|
||||
|
||||
public function test_issue_type_label_returns_correct_text(): void
|
||||
{
|
||||
$issue = Issue::factory()->create(['issue_type' => Issue::TYPE_WORK_ITEM]);
|
||||
$this->assertEquals('Work Item', $issue->issue_type_label);
|
||||
|
||||
$issue->issue_type = Issue::TYPE_MEMBER_REQUEST;
|
||||
$this->assertEquals('Member Request', $issue->issue_type_label);
|
||||
}
|
||||
}
|
||||
266
tests/Unit/MemberTest.php
Normal file
266
tests/Unit/MemberTest.php
Normal file
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\User;
|
||||
use App\Models\MembershipPayment;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MemberTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->artisan('db:seed', ['--class' => 'RoleSeeder']);
|
||||
}
|
||||
|
||||
public function test_member_has_required_fillable_fields(): void
|
||||
{
|
||||
$member = Member::factory()->create([
|
||||
'full_name' => 'Test Member',
|
||||
'email' => 'test@example.com',
|
||||
'phone' => '0912345678',
|
||||
]);
|
||||
|
||||
$this->assertEquals('Test Member', $member->full_name);
|
||||
$this->assertEquals('test@example.com', $member->email);
|
||||
$this->assertEquals('0912345678', $member->phone);
|
||||
}
|
||||
|
||||
public function test_member_belongs_to_user(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$member = Member::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$this->assertInstanceOf(User::class, $member->user);
|
||||
$this->assertEquals($user->id, $member->user->id);
|
||||
}
|
||||
|
||||
public function test_member_has_many_payments(): void
|
||||
{
|
||||
$member = Member::factory()->create();
|
||||
$payment1 = MembershipPayment::factory()->create(['member_id' => $member->id]);
|
||||
$payment2 = MembershipPayment::factory()->create(['member_id' => $member->id]);
|
||||
|
||||
$this->assertCount(2, $member->payments);
|
||||
$this->assertTrue($member->payments->contains($payment1));
|
||||
$this->assertTrue($member->payments->contains($payment2));
|
||||
}
|
||||
|
||||
public function test_has_paid_membership_returns_true_when_active_with_future_expiry(): void
|
||||
{
|
||||
$member = Member::factory()->create([
|
||||
'membership_status' => Member::STATUS_ACTIVE,
|
||||
'membership_started_at' => now()->subMonth(),
|
||||
'membership_expires_at' => now()->addYear(),
|
||||
]);
|
||||
|
||||
$this->assertTrue($member->hasPaidMembership());
|
||||
}
|
||||
|
||||
public function test_has_paid_membership_returns_false_when_pending(): void
|
||||
{
|
||||
$member = Member::factory()->create([
|
||||
'membership_status' => Member::STATUS_PENDING,
|
||||
'membership_started_at' => null,
|
||||
'membership_expires_at' => null,
|
||||
]);
|
||||
|
||||
$this->assertFalse($member->hasPaidMembership());
|
||||
}
|
||||
|
||||
public function test_has_paid_membership_returns_false_when_expired(): void
|
||||
{
|
||||
$member = Member::factory()->create([
|
||||
'membership_status' => Member::STATUS_ACTIVE,
|
||||
'membership_started_at' => now()->subYear()->subMonth(),
|
||||
'membership_expires_at' => now()->subMonth(),
|
||||
]);
|
||||
|
||||
$this->assertFalse($member->hasPaidMembership());
|
||||
}
|
||||
|
||||
public function test_has_paid_membership_returns_false_when_suspended(): void
|
||||
{
|
||||
$member = Member::factory()->create([
|
||||
'membership_status' => Member::STATUS_SUSPENDED,
|
||||
'membership_started_at' => now()->subMonth(),
|
||||
'membership_expires_at' => now()->addYear(),
|
||||
]);
|
||||
|
||||
$this->assertFalse($member->hasPaidMembership());
|
||||
}
|
||||
|
||||
public function test_can_submit_payment_returns_true_when_pending_and_no_pending_payment(): void
|
||||
{
|
||||
$member = Member::factory()->create([
|
||||
'membership_status' => Member::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
$this->assertTrue($member->canSubmitPayment());
|
||||
}
|
||||
|
||||
public function test_can_submit_payment_returns_false_when_already_has_pending_payment(): void
|
||||
{
|
||||
$member = Member::factory()->create([
|
||||
'membership_status' => Member::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
MembershipPayment::factory()->create([
|
||||
'member_id' => $member->id,
|
||||
'status' => MembershipPayment::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
$this->assertFalse($member->canSubmitPayment());
|
||||
}
|
||||
|
||||
public function test_can_submit_payment_returns_false_when_active(): void
|
||||
{
|
||||
$member = Member::factory()->create([
|
||||
'membership_status' => Member::STATUS_ACTIVE,
|
||||
]);
|
||||
|
||||
$this->assertFalse($member->canSubmitPayment());
|
||||
}
|
||||
|
||||
public function test_get_pending_payment_returns_pending_payment(): void
|
||||
{
|
||||
$member = Member::factory()->create();
|
||||
|
||||
$pendingPayment = MembershipPayment::factory()->create([
|
||||
'member_id' => $member->id,
|
||||
'status' => MembershipPayment::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
$approvedPayment = MembershipPayment::factory()->create([
|
||||
'member_id' => $member->id,
|
||||
'status' => MembershipPayment::STATUS_APPROVED_CHAIR,
|
||||
]);
|
||||
|
||||
$result = $member->getPendingPayment();
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertEquals($pendingPayment->id, $result->id);
|
||||
}
|
||||
|
||||
public function test_get_pending_payment_returns_null_when_no_pending_payments(): void
|
||||
{
|
||||
$member = Member::factory()->create();
|
||||
|
||||
MembershipPayment::factory()->create([
|
||||
'member_id' => $member->id,
|
||||
'status' => MembershipPayment::STATUS_APPROVED_CHAIR,
|
||||
]);
|
||||
|
||||
$this->assertNull($member->getPendingPayment());
|
||||
}
|
||||
|
||||
public function test_national_id_encryption_and_decryption(): void
|
||||
{
|
||||
$member = Member::factory()->create([
|
||||
'full_name' => 'Test Member',
|
||||
]);
|
||||
|
||||
$nationalId = 'A123456789';
|
||||
$member->national_id = $nationalId;
|
||||
$member->save();
|
||||
|
||||
// Refresh from database
|
||||
$member->refresh();
|
||||
|
||||
// Check encrypted value is different from plain text
|
||||
$this->assertNotEquals($nationalId, $member->national_id_encrypted);
|
||||
|
||||
// Check decryption works
|
||||
$this->assertEquals($nationalId, $member->national_id);
|
||||
}
|
||||
|
||||
public function test_national_id_hash_created_for_search(): void
|
||||
{
|
||||
$member = Member::factory()->create();
|
||||
$nationalId = 'A123456789';
|
||||
|
||||
$member->national_id = $nationalId;
|
||||
$member->save();
|
||||
|
||||
$member->refresh();
|
||||
|
||||
// Check hash was created
|
||||
$this->assertNotNull($member->national_id_hash);
|
||||
|
||||
// Check hash matches SHA256
|
||||
$expectedHash = hash('sha256', $nationalId);
|
||||
$this->assertEquals($expectedHash, $member->national_id_hash);
|
||||
}
|
||||
|
||||
public function test_is_pending_returns_true_when_status_is_pending(): void
|
||||
{
|
||||
$member = Member::factory()->create(['membership_status' => Member::STATUS_PENDING]);
|
||||
$this->assertTrue($member->isPending());
|
||||
}
|
||||
|
||||
public function test_is_active_returns_true_when_status_is_active(): void
|
||||
{
|
||||
$member = Member::factory()->create(['membership_status' => Member::STATUS_ACTIVE]);
|
||||
$this->assertTrue($member->isActive());
|
||||
}
|
||||
|
||||
public function test_is_expired_returns_true_when_status_is_expired(): void
|
||||
{
|
||||
$member = Member::factory()->create(['membership_status' => Member::STATUS_EXPIRED]);
|
||||
$this->assertTrue($member->isExpired());
|
||||
}
|
||||
|
||||
public function test_is_suspended_returns_true_when_status_is_suspended(): void
|
||||
{
|
||||
$member = Member::factory()->create(['membership_status' => Member::STATUS_SUSPENDED]);
|
||||
$this->assertTrue($member->isSuspended());
|
||||
}
|
||||
|
||||
public function test_membership_status_label_returns_chinese_text(): void
|
||||
{
|
||||
$member = Member::factory()->create(['membership_status' => Member::STATUS_PENDING]);
|
||||
$this->assertEquals('待繳費', $member->membership_status_label);
|
||||
|
||||
$member->membership_status = Member::STATUS_ACTIVE;
|
||||
$this->assertEquals('已啟用', $member->membership_status_label);
|
||||
|
||||
$member->membership_status = Member::STATUS_EXPIRED;
|
||||
$this->assertEquals('已過期', $member->membership_status_label);
|
||||
|
||||
$member->membership_status = Member::STATUS_SUSPENDED;
|
||||
$this->assertEquals('已暫停', $member->membership_status_label);
|
||||
}
|
||||
|
||||
public function test_membership_type_label_returns_chinese_text(): void
|
||||
{
|
||||
$member = Member::factory()->create(['membership_type' => Member::TYPE_REGULAR]);
|
||||
$this->assertEquals('一般會員', $member->membership_type_label);
|
||||
|
||||
$member->membership_type = Member::TYPE_STUDENT;
|
||||
$this->assertEquals('學生會員', $member->membership_type_label);
|
||||
|
||||
$member->membership_type = Member::TYPE_HONORARY;
|
||||
$this->assertEquals('榮譽會員', $member->membership_type_label);
|
||||
|
||||
$member->membership_type = Member::TYPE_LIFETIME;
|
||||
$this->assertEquals('終身會員', $member->membership_type_label);
|
||||
}
|
||||
|
||||
public function test_membership_status_badge_returns_correct_css_class(): void
|
||||
{
|
||||
$member = Member::factory()->create(['membership_status' => Member::STATUS_PENDING]);
|
||||
$badge = $member->membership_status_badge;
|
||||
|
||||
$this->assertStringContainsString('待繳費', $badge);
|
||||
$this->assertStringContainsString('bg-yellow', $badge);
|
||||
|
||||
$member->membership_status = Member::STATUS_ACTIVE;
|
||||
$badge = $member->membership_status_badge;
|
||||
$this->assertStringContainsString('bg-green', $badge);
|
||||
}
|
||||
}
|
||||
230
tests/Unit/MembershipPaymentTest.php
Normal file
230
tests/Unit/MembershipPaymentTest.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\MembershipPayment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MembershipPaymentTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Storage::fake('private');
|
||||
$this->artisan('db:seed', ['--class' => 'RoleSeeder']);
|
||||
}
|
||||
|
||||
public function test_payment_belongs_to_member(): void
|
||||
{
|
||||
$member = Member::factory()->create();
|
||||
$payment = MembershipPayment::factory()->create(['member_id' => $member->id]);
|
||||
|
||||
$this->assertInstanceOf(Member::class, $payment->member);
|
||||
$this->assertEquals($member->id, $payment->member->id);
|
||||
}
|
||||
|
||||
public function test_payment_belongs_to_submitted_by_user(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$payment = MembershipPayment::factory()->create(['submitted_by_user_id' => $user->id]);
|
||||
|
||||
$this->assertInstanceOf(User::class, $payment->submittedBy);
|
||||
$this->assertEquals($user->id, $payment->submittedBy->id);
|
||||
}
|
||||
|
||||
public function test_payment_has_verifier_relationships(): void
|
||||
{
|
||||
$cashier = User::factory()->create();
|
||||
$accountant = User::factory()->create();
|
||||
$chair = User::factory()->create();
|
||||
|
||||
$payment = MembershipPayment::factory()->create([
|
||||
'verified_by_cashier_id' => $cashier->id,
|
||||
'verified_by_accountant_id' => $accountant->id,
|
||||
'verified_by_chair_id' => $chair->id,
|
||||
]);
|
||||
|
||||
$this->assertInstanceOf(User::class, $payment->verifiedByCashier);
|
||||
$this->assertInstanceOf(User::class, $payment->verifiedByAccountant);
|
||||
$this->assertInstanceOf(User::class, $payment->verifiedByChair);
|
||||
|
||||
$this->assertEquals($cashier->id, $payment->verifiedByCashier->id);
|
||||
$this->assertEquals($accountant->id, $payment->verifiedByAccountant->id);
|
||||
$this->assertEquals($chair->id, $payment->verifiedByChair->id);
|
||||
}
|
||||
|
||||
public function test_is_pending_returns_true_when_status_is_pending(): void
|
||||
{
|
||||
$payment = MembershipPayment::factory()->create(['status' => MembershipPayment::STATUS_PENDING]);
|
||||
$this->assertTrue($payment->isPending());
|
||||
}
|
||||
|
||||
public function test_is_approved_by_cashier_returns_true_when_status_is_approved_cashier(): void
|
||||
{
|
||||
$payment = MembershipPayment::factory()->create(['status' => MembershipPayment::STATUS_APPROVED_CASHIER]);
|
||||
$this->assertTrue($payment->isApprovedByCashier());
|
||||
}
|
||||
|
||||
public function test_is_approved_by_accountant_returns_true_when_status_is_approved_accountant(): void
|
||||
{
|
||||
$payment = MembershipPayment::factory()->create(['status' => MembershipPayment::STATUS_APPROVED_ACCOUNTANT]);
|
||||
$this->assertTrue($payment->isApprovedByAccountant());
|
||||
}
|
||||
|
||||
public function test_is_fully_approved_returns_true_when_status_is_approved_chair(): void
|
||||
{
|
||||
$payment = MembershipPayment::factory()->create(['status' => MembershipPayment::STATUS_APPROVED_CHAIR]);
|
||||
$this->assertTrue($payment->isFullyApproved());
|
||||
}
|
||||
|
||||
public function test_is_rejected_returns_true_when_status_is_rejected(): void
|
||||
{
|
||||
$payment = MembershipPayment::factory()->create(['status' => MembershipPayment::STATUS_REJECTED]);
|
||||
$this->assertTrue($payment->isRejected());
|
||||
}
|
||||
|
||||
public function test_can_be_approved_by_cashier_validates_correctly(): void
|
||||
{
|
||||
$pendingPayment = MembershipPayment::factory()->create(['status' => MembershipPayment::STATUS_PENDING]);
|
||||
$this->assertTrue($pendingPayment->canBeApprovedByCashier());
|
||||
|
||||
$approvedPayment = MembershipPayment::factory()->create(['status' => MembershipPayment::STATUS_APPROVED_CASHIER]);
|
||||
$this->assertFalse($approvedPayment->canBeApprovedByCashier());
|
||||
}
|
||||
|
||||
public function test_can_be_approved_by_accountant_validates_correctly(): void
|
||||
{
|
||||
$cashierApprovedPayment = MembershipPayment::factory()->create(['status' => MembershipPayment::STATUS_APPROVED_CASHIER]);
|
||||
$this->assertTrue($cashierApprovedPayment->canBeApprovedByAccountant());
|
||||
|
||||
$pendingPayment = MembershipPayment::factory()->create(['status' => MembershipPayment::STATUS_PENDING]);
|
||||
$this->assertFalse($pendingPayment->canBeApprovedByAccountant());
|
||||
}
|
||||
|
||||
public function test_can_be_approved_by_chair_validates_correctly(): void
|
||||
{
|
||||
$accountantApprovedPayment = MembershipPayment::factory()->create(['status' => MembershipPayment::STATUS_APPROVED_ACCOUNTANT]);
|
||||
$this->assertTrue($accountantApprovedPayment->canBeApprovedByChair());
|
||||
|
||||
$cashierApprovedPayment = MembershipPayment::factory()->create(['status' => MembershipPayment::STATUS_APPROVED_CASHIER]);
|
||||
$this->assertFalse($cashierApprovedPayment->canBeApprovedByChair());
|
||||
}
|
||||
|
||||
public function test_workflow_validation_prevents_skipping_tiers(): void
|
||||
{
|
||||
$payment = MembershipPayment::factory()->create(['status' => MembershipPayment::STATUS_PENDING]);
|
||||
|
||||
// Cannot skip to accountant approval
|
||||
$this->assertFalse($payment->canBeApprovedByAccountant());
|
||||
|
||||
// Cannot skip to chair approval
|
||||
$this->assertFalse($payment->canBeApprovedByChair());
|
||||
|
||||
// Must go through cashier first
|
||||
$this->assertTrue($payment->canBeApprovedByCashier());
|
||||
}
|
||||
|
||||
public function test_status_label_returns_chinese_text(): void
|
||||
{
|
||||
$payment = MembershipPayment::factory()->create(['status' => MembershipPayment::STATUS_PENDING]);
|
||||
$this->assertEquals('待審核', $payment->status_label);
|
||||
|
||||
$payment->status = MembershipPayment::STATUS_APPROVED_CASHIER;
|
||||
$this->assertEquals('出納已審', $payment->status_label);
|
||||
|
||||
$payment->status = MembershipPayment::STATUS_APPROVED_ACCOUNTANT;
|
||||
$this->assertEquals('會計已審', $payment->status_label);
|
||||
|
||||
$payment->status = MembershipPayment::STATUS_APPROVED_CHAIR;
|
||||
$this->assertEquals('主席已審', $payment->status_label);
|
||||
|
||||
$payment->status = MembershipPayment::STATUS_REJECTED;
|
||||
$this->assertEquals('已拒絕', $payment->status_label);
|
||||
}
|
||||
|
||||
public function test_payment_method_label_returns_chinese_text(): void
|
||||
{
|
||||
$payment = MembershipPayment::factory()->create(['payment_method' => MembershipPayment::METHOD_BANK_TRANSFER]);
|
||||
$this->assertEquals('銀行轉帳', $payment->payment_method_label);
|
||||
|
||||
$payment->payment_method = MembershipPayment::METHOD_CONVENIENCE_STORE;
|
||||
$this->assertEquals('超商繳費', $payment->payment_method_label);
|
||||
|
||||
$payment->payment_method = MembershipPayment::METHOD_CASH;
|
||||
$this->assertEquals('現金', $payment->payment_method_label);
|
||||
|
||||
$payment->payment_method = MembershipPayment::METHOD_CREDIT_CARD;
|
||||
$this->assertEquals('信用卡', $payment->payment_method_label);
|
||||
}
|
||||
|
||||
public function test_receipt_file_cleanup_on_deletion(): void
|
||||
{
|
||||
Storage::fake('private');
|
||||
|
||||
$payment = MembershipPayment::factory()->create([
|
||||
'receipt_path' => 'payment-receipts/test-receipt.pdf'
|
||||
]);
|
||||
|
||||
// Create fake file
|
||||
Storage::disk('private')->put('payment-receipts/test-receipt.pdf', 'test content');
|
||||
|
||||
$this->assertTrue(Storage::disk('private')->exists('payment-receipts/test-receipt.pdf'));
|
||||
|
||||
// Delete payment should delete file
|
||||
$payment->delete();
|
||||
|
||||
$this->assertFalse(Storage::disk('private')->exists('payment-receipts/test-receipt.pdf'));
|
||||
}
|
||||
|
||||
public function test_rejection_tracking_works(): void
|
||||
{
|
||||
$rejector = User::factory()->create();
|
||||
$payment = MembershipPayment::factory()->create([
|
||||
'status' => MembershipPayment::STATUS_REJECTED,
|
||||
'rejected_by_user_id' => $rejector->id,
|
||||
'rejected_at' => now(),
|
||||
'rejection_reason' => 'Invalid receipt',
|
||||
]);
|
||||
|
||||
$this->assertTrue($payment->isRejected());
|
||||
$this->assertInstanceOf(User::class, $payment->rejectedBy);
|
||||
$this->assertEquals($rejector->id, $payment->rejectedBy->id);
|
||||
$this->assertEquals('Invalid receipt', $payment->rejection_reason);
|
||||
$this->assertNotNull($payment->rejected_at);
|
||||
}
|
||||
|
||||
public function test_payment_workflow_complete_sequence(): void
|
||||
{
|
||||
$payment = MembershipPayment::factory()->create(['status' => MembershipPayment::STATUS_PENDING]);
|
||||
|
||||
// Step 1: Pending - can only be approved by cashier
|
||||
$this->assertTrue($payment->canBeApprovedByCashier());
|
||||
$this->assertFalse($payment->canBeApprovedByAccountant());
|
||||
$this->assertFalse($payment->canBeApprovedByChair());
|
||||
|
||||
// Step 2: Cashier approved - can only be approved by accountant
|
||||
$payment->status = MembershipPayment::STATUS_APPROVED_CASHIER;
|
||||
$this->assertFalse($payment->canBeApprovedByCashier());
|
||||
$this->assertTrue($payment->canBeApprovedByAccountant());
|
||||
$this->assertFalse($payment->canBeApprovedByChair());
|
||||
|
||||
// Step 3: Accountant approved - can only be approved by chair
|
||||
$payment->status = MembershipPayment::STATUS_APPROVED_ACCOUNTANT;
|
||||
$this->assertFalse($payment->canBeApprovedByCashier());
|
||||
$this->assertFalse($payment->canBeApprovedByAccountant());
|
||||
$this->assertTrue($payment->canBeApprovedByChair());
|
||||
|
||||
// Step 4: Chair approved - workflow complete
|
||||
$payment->status = MembershipPayment::STATUS_APPROVED_CHAIR;
|
||||
$this->assertFalse($payment->canBeApprovedByCashier());
|
||||
$this->assertFalse($payment->canBeApprovedByAccountant());
|
||||
$this->assertFalse($payment->canBeApprovedByChair());
|
||||
$this->assertTrue($payment->isFullyApproved());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user