Initial commit

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

View File

@@ -0,0 +1,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
View 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());
}
}

View 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);
}
}

View 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
View 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
View 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);
}
}

View 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());
}
}