Add phone login support and member import functionality
Features: - Support login via phone number or email (LoginRequest) - Add members:import-roster command for Excel roster import - Merge survey emails with roster data Code Quality (Phase 1-4): - Add database locking for balance calculation - Add self-approval checks for finance workflow - Create service layer (FinanceDocumentApprovalService, PaymentVerificationService) - Add HasAccountingEntries and HasApprovalWorkflow traits - Create FormRequest classes for validation - Add status-badge component - Define authorization gates in AuthServiceProvider - Add accounting config file Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ use Tests\Traits\SeedsRolesAndPermissions;
|
||||
* Finance Email Content Tests
|
||||
*
|
||||
* Tests email content for finance document-related notifications.
|
||||
* Uses new workflow: Secretary → Chair → Board
|
||||
*/
|
||||
class FinanceEmailContentTest extends TestCase
|
||||
{
|
||||
@@ -33,80 +34,80 @@ class FinanceEmailContentTest extends TestCase
|
||||
*/
|
||||
public function test_finance_document_submitted_email(): void
|
||||
{
|
||||
$accountant = $this->createAccountant();
|
||||
$requester = $this->createAdmin();
|
||||
|
||||
$this->actingAs($accountant)->post(
|
||||
route('admin.finance-documents.store'),
|
||||
$this->actingAs($requester)->post(
|
||||
route('admin.finance.store'),
|
||||
$this->getValidFinanceDocumentData(['title' => 'Test Finance Request'])
|
||||
);
|
||||
|
||||
// Verify email was queued (if system sends submission notifications)
|
||||
$this->assertTrue(true);
|
||||
// Verify document was created
|
||||
$this->assertDatabaseHas('finance_documents', ['title' => 'Test Finance Request']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test finance document approved by cashier email
|
||||
* Test finance document approved by secretary email
|
||||
*/
|
||||
public function test_finance_document_approved_by_cashier_email(): void
|
||||
public function test_finance_document_approved_by_secretary_email(): void
|
||||
{
|
||||
$cashier = $this->createCashier();
|
||||
$secretary = $this->createSecretary();
|
||||
$document = $this->createFinanceDocument([
|
||||
'status' => FinanceDocument::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
$this->actingAs($cashier)->post(
|
||||
route('admin.finance-documents.approve', $document)
|
||||
$this->actingAs($secretary)->post(
|
||||
route('admin.finance.approve', $document)
|
||||
);
|
||||
|
||||
// Verify approval notification was triggered
|
||||
$document->refresh();
|
||||
$this->assertEquals(FinanceDocument::STATUS_APPROVED_CASHIER, $document->status);
|
||||
$this->assertEquals(FinanceDocument::STATUS_APPROVED_SECRETARY, $document->status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test finance document approved by accountant email
|
||||
* Test finance document approved by chair email
|
||||
*/
|
||||
public function test_finance_document_approved_by_accountant_email(): void
|
||||
{
|
||||
$accountant = $this->createAccountant();
|
||||
$document = $this->createDocumentAtStage('cashier_approved');
|
||||
|
||||
$this->actingAs($accountant)->post(
|
||||
route('admin.finance-documents.approve', $document)
|
||||
);
|
||||
|
||||
$document->refresh();
|
||||
$this->assertEquals(FinanceDocument::STATUS_APPROVED_ACCOUNTANT, $document->status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test finance document fully approved email
|
||||
*/
|
||||
public function test_finance_document_fully_approved_email(): void
|
||||
public function test_finance_document_approved_by_chair_email(): void
|
||||
{
|
||||
$chair = $this->createChair();
|
||||
$document = $this->createDocumentAtStage('accountant_approved');
|
||||
$document = $this->createDocumentAtStage('secretary_approved', ['amount' => 25000]);
|
||||
|
||||
$this->actingAs($chair)->post(
|
||||
route('admin.finance-documents.approve', $document)
|
||||
route('admin.finance.approve', $document)
|
||||
);
|
||||
|
||||
$document->refresh();
|
||||
$this->assertEquals(FinanceDocument::STATUS_APPROVED_CHAIR, $document->status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test finance document fully approved by board email
|
||||
*/
|
||||
public function test_finance_document_fully_approved_by_board_email(): void
|
||||
{
|
||||
$boardMember = $this->createBoardMember();
|
||||
$document = $this->createDocumentAtStage('chair_approved', ['amount' => 75000]);
|
||||
|
||||
$this->actingAs($boardMember)->post(
|
||||
route('admin.finance.approve', $document)
|
||||
);
|
||||
|
||||
$document->refresh();
|
||||
$this->assertEquals(FinanceDocument::STATUS_APPROVED_BOARD, $document->status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test finance document rejected email
|
||||
*/
|
||||
public function test_finance_document_rejected_email(): void
|
||||
{
|
||||
$cashier = $this->createCashier();
|
||||
$secretary = $this->createSecretary();
|
||||
$document = $this->createFinanceDocument([
|
||||
'status' => FinanceDocument::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
$this->actingAs($cashier)->post(
|
||||
route('admin.finance-documents.reject', $document),
|
||||
$this->actingAs($secretary)->post(
|
||||
route('admin.finance.reject', $document),
|
||||
['rejection_reason' => 'Insufficient documentation']
|
||||
);
|
||||
|
||||
@@ -141,48 +142,6 @@ class FinanceEmailContentTest extends TestCase
|
||||
$this->assertEquals('Office Supplies Purchase', $document->title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test email contains approval notes
|
||||
*/
|
||||
public function test_email_contains_approval_notes(): void
|
||||
{
|
||||
$cashier = $this->createCashier();
|
||||
$document = $this->createFinanceDocument([
|
||||
'status' => FinanceDocument::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
$this->actingAs($cashier)->post(
|
||||
route('admin.finance-documents.approve', $document),
|
||||
['notes' => 'Approved after verification']
|
||||
);
|
||||
|
||||
// Notes should be stored if the controller supports it
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test email sent to all approvers
|
||||
*/
|
||||
public function test_email_sent_to_all_approvers(): void
|
||||
{
|
||||
$cashier = $this->createCashier(['email' => 'cashier@test.com']);
|
||||
$accountant = $this->createAccountant(['email' => 'accountant@test.com']);
|
||||
$chair = $this->createChair(['email' => 'chair@test.com']);
|
||||
|
||||
$document = $this->createFinanceDocument([
|
||||
'status' => FinanceDocument::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
// Approval should trigger notifications to next approver
|
||||
$this->actingAs($cashier)->post(
|
||||
route('admin.finance-documents.approve', $document)
|
||||
);
|
||||
|
||||
// Accountant should be notified
|
||||
$document->refresh();
|
||||
$this->assertEquals(FinanceDocument::STATUS_APPROVED_CASHIER, $document->status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test email template renders correctly
|
||||
*/
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Email;
|
||||
|
||||
use App\Models\Issue;
|
||||
use App\Models\IssueComment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
use Tests\Traits\SeedsRolesAndPermissions;
|
||||
|
||||
/**
|
||||
* Issue Email Content Tests
|
||||
*
|
||||
* Tests email content for issue tracking-related notifications.
|
||||
*/
|
||||
class IssueEmailContentTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase, SeedsRolesAndPermissions;
|
||||
|
||||
protected User $admin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Mail::fake();
|
||||
$this->seedRolesAndPermissions();
|
||||
$this->admin = $this->createAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test issue assigned email
|
||||
*/
|
||||
public function test_issue_assigned_email(): void
|
||||
{
|
||||
$assignee = User::factory()->create(['email' => 'assignee@test.com']);
|
||||
|
||||
$issue = Issue::factory()->create([
|
||||
'created_by_user_id' => $this->admin->id,
|
||||
'assignee_id' => null,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->admin)->post(
|
||||
route('admin.issues.assign', $issue),
|
||||
['assignee_id' => $assignee->id]
|
||||
);
|
||||
|
||||
$issue->refresh();
|
||||
$this->assertEquals($assignee->id, $issue->assignee_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test issue status changed email
|
||||
*/
|
||||
public function test_issue_status_changed_email(): void
|
||||
{
|
||||
$issue = Issue::factory()->create([
|
||||
'created_by_user_id' => $this->admin->id,
|
||||
'status' => Issue::STATUS_NEW,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->admin)->patch(
|
||||
route('admin.issues.status', $issue),
|
||||
['status' => Issue::STATUS_IN_PROGRESS]
|
||||
);
|
||||
|
||||
$issue->refresh();
|
||||
$this->assertEquals(Issue::STATUS_IN_PROGRESS, $issue->status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test issue commented email
|
||||
*/
|
||||
public function test_issue_commented_email(): void
|
||||
{
|
||||
$issue = Issue::factory()->create([
|
||||
'created_by_user_id' => $this->admin->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->admin)->post(
|
||||
route('admin.issues.comments.store', $issue),
|
||||
['content' => 'This is a test comment']
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('issue_comments', [
|
||||
'issue_id' => $issue->id,
|
||||
'content' => 'This is a test comment',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test issue due soon email
|
||||
*/
|
||||
public function test_issue_due_soon_email(): void
|
||||
{
|
||||
$issue = Issue::factory()->create([
|
||||
'created_by_user_id' => $this->admin->id,
|
||||
'due_date' => now()->addDays(2),
|
||||
'status' => Issue::STATUS_IN_PROGRESS,
|
||||
]);
|
||||
|
||||
// Issue is due soon (within 3 days)
|
||||
$this->assertTrue($issue->due_date->diffInDays(now()) <= 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test issue overdue email
|
||||
*/
|
||||
public function test_issue_overdue_email(): void
|
||||
{
|
||||
$issue = Issue::factory()->create([
|
||||
'created_by_user_id' => $this->admin->id,
|
||||
'due_date' => now()->subDay(),
|
||||
'status' => Issue::STATUS_IN_PROGRESS,
|
||||
]);
|
||||
|
||||
$this->assertTrue($issue->isOverdue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test issue closed email
|
||||
*/
|
||||
public function test_issue_closed_email(): void
|
||||
{
|
||||
$issue = Issue::factory()->create([
|
||||
'created_by_user_id' => $this->admin->id,
|
||||
'status' => Issue::STATUS_REVIEW,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->admin)->patch(
|
||||
route('admin.issues.status', $issue),
|
||||
['status' => Issue::STATUS_CLOSED]
|
||||
);
|
||||
|
||||
$issue->refresh();
|
||||
$this->assertEquals(Issue::STATUS_CLOSED, $issue->status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test email sent to watchers
|
||||
*/
|
||||
public function test_email_sent_to_watchers(): void
|
||||
{
|
||||
$watcher = User::factory()->create(['email' => 'watcher@test.com']);
|
||||
|
||||
$issue = Issue::factory()->create([
|
||||
'created_by_user_id' => $this->admin->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->admin)->post(
|
||||
route('admin.issues.watchers.store', $issue),
|
||||
['user_id' => $watcher->id]
|
||||
);
|
||||
|
||||
// Watcher should be added
|
||||
$this->assertTrue($issue->watchers->contains($watcher));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test email contains issue link
|
||||
*/
|
||||
public function test_email_contains_issue_link(): void
|
||||
{
|
||||
$issue = Issue::factory()->create([
|
||||
'created_by_user_id' => $this->admin->id,
|
||||
]);
|
||||
|
||||
$issueUrl = route('admin.issues.show', $issue);
|
||||
$this->assertStringContainsString('/admin/issues/', $issueUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test email contains issue details
|
||||
*/
|
||||
public function test_email_contains_issue_details(): void
|
||||
{
|
||||
$issue = Issue::factory()->create([
|
||||
'created_by_user_id' => $this->admin->id,
|
||||
'title' => 'Important Task',
|
||||
'description' => 'This needs to be done urgently',
|
||||
'priority' => Issue::PRIORITY_HIGH,
|
||||
]);
|
||||
|
||||
$this->assertEquals('Important Task', $issue->title);
|
||||
$this->assertEquals('This needs to be done urgently', $issue->description);
|
||||
$this->assertEquals(Issue::PRIORITY_HIGH, $issue->priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test email formatting is correct
|
||||
*/
|
||||
public function test_email_formatting_is_correct(): void
|
||||
{
|
||||
$issue = Issue::factory()->create([
|
||||
'created_by_user_id' => $this->admin->id,
|
||||
'title' => 'Test Issue',
|
||||
]);
|
||||
|
||||
// Verify issue number is properly formatted
|
||||
$this->assertMatchesRegularExpression('/ISS-\d{4}-\d+/', $issue->issue_number);
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Email;
|
||||
|
||||
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\Mail\WelcomeMemberMail;
|
||||
use App\Models\Member;
|
||||
use App\Models\MembershipPayment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
use Tests\Traits\CreatesMemberData;
|
||||
use Tests\Traits\SeedsRolesAndPermissions;
|
||||
|
||||
/**
|
||||
* Membership Email Content Tests
|
||||
*
|
||||
* Tests email content, recipients, and subjects for membership-related emails.
|
||||
*/
|
||||
class MembershipEmailContentTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase, SeedsRolesAndPermissions, CreatesMemberData;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Storage::fake('private');
|
||||
$this->seedRolesAndPermissions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test welcome email has correct subject
|
||||
*/
|
||||
public function test_welcome_email_has_correct_subject(): void
|
||||
{
|
||||
$member = $this->createPendingMember();
|
||||
$mail = new WelcomeMemberMail($member);
|
||||
|
||||
$this->assertStringContainsString('歡迎', $mail->envelope()->subject);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test welcome email contains member name
|
||||
*/
|
||||
public function test_welcome_email_contains_member_name(): void
|
||||
{
|
||||
$member = $this->createPendingMember(['full_name' => 'Test Member Name']);
|
||||
$mail = new WelcomeMemberMail($member);
|
||||
|
||||
$rendered = $mail->render();
|
||||
$this->assertStringContainsString('Test Member Name', $rendered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test welcome email contains dashboard link
|
||||
*/
|
||||
public function test_welcome_email_contains_dashboard_link(): void
|
||||
{
|
||||
$member = $this->createPendingMember();
|
||||
$mail = new WelcomeMemberMail($member);
|
||||
|
||||
$rendered = $mail->render();
|
||||
$this->assertStringContainsString(route('member.dashboard'), $rendered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test welcome email sent to correct recipient
|
||||
*/
|
||||
public function test_welcome_email_sent_to_correct_recipient(): void
|
||||
{
|
||||
Mail::fake();
|
||||
|
||||
$data = $this->getValidMemberRegistrationData(['email' => 'newmember@test.com']);
|
||||
$this->post(route('register.member.store'), $data);
|
||||
|
||||
Mail::assertQueued(WelcomeMemberMail::class, function ($mail) {
|
||||
return $mail->hasTo('newmember@test.com');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test payment submitted email to member
|
||||
*/
|
||||
public function test_payment_submitted_email_to_member(): void
|
||||
{
|
||||
$data = $this->createMemberWithPendingPayment();
|
||||
$member = $data['member'];
|
||||
$payment = $data['payment'];
|
||||
|
||||
$mail = new PaymentSubmittedMail($payment, $member->user, 'member');
|
||||
|
||||
$rendered = $mail->render();
|
||||
$this->assertStringContainsString((string) $payment->amount, $rendered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test payment submitted email to cashier
|
||||
*/
|
||||
public function test_payment_submitted_email_to_cashier(): void
|
||||
{
|
||||
$data = $this->createMemberWithPendingPayment();
|
||||
$member = $data['member'];
|
||||
$payment = $data['payment'];
|
||||
$cashier = $this->createCashier();
|
||||
|
||||
$mail = new PaymentSubmittedMail($payment, $cashier, 'cashier');
|
||||
|
||||
$rendered = $mail->render();
|
||||
$this->assertStringContainsString($member->full_name, $rendered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test payment approved by cashier email
|
||||
*/
|
||||
public function test_payment_approved_by_cashier_email(): void
|
||||
{
|
||||
$data = $this->createMemberWithPaymentAtStage('cashier_approved');
|
||||
$payment = $data['payment'];
|
||||
|
||||
$mail = new PaymentApprovedByCashierMail($payment);
|
||||
|
||||
$this->assertNotNull($mail->envelope()->subject);
|
||||
$rendered = $mail->render();
|
||||
$this->assertNotEmpty($rendered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test payment approved by accountant email
|
||||
*/
|
||||
public function test_payment_approved_by_accountant_email(): void
|
||||
{
|
||||
$data = $this->createMemberWithPaymentAtStage('accountant_approved');
|
||||
$payment = $data['payment'];
|
||||
|
||||
$mail = new PaymentApprovedByAccountantMail($payment);
|
||||
|
||||
$this->assertNotNull($mail->envelope()->subject);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test payment fully approved email
|
||||
*/
|
||||
public function test_payment_fully_approved_email(): void
|
||||
{
|
||||
$data = $this->createMemberWithPaymentAtStage('fully_approved');
|
||||
$payment = $data['payment'];
|
||||
|
||||
$mail = new PaymentFullyApprovedMail($payment);
|
||||
|
||||
$rendered = $mail->render();
|
||||
$this->assertNotEmpty($rendered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test payment rejected email contains reason
|
||||
*/
|
||||
public function test_payment_rejected_email_contains_reason(): void
|
||||
{
|
||||
$data = $this->createMemberWithPendingPayment();
|
||||
$payment = $data['payment'];
|
||||
$payment->update([
|
||||
'status' => MembershipPayment::STATUS_REJECTED,
|
||||
'rejection_reason' => 'Receipt is not clear',
|
||||
]);
|
||||
|
||||
$mail = new PaymentRejectedMail($payment);
|
||||
|
||||
$rendered = $mail->render();
|
||||
$this->assertStringContainsString('Receipt is not clear', $rendered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test membership activated email
|
||||
*/
|
||||
public function test_membership_activated_email(): void
|
||||
{
|
||||
$member = $this->createActiveMember();
|
||||
|
||||
$mail = new MembershipActivatedMail($member);
|
||||
|
||||
$rendered = $mail->render();
|
||||
$this->assertStringContainsString($member->full_name, $rendered);
|
||||
$this->assertStringContainsString('啟用', $rendered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test membership expiry reminder email
|
||||
* Note: This test is for if the system has expiry reminder functionality
|
||||
*/
|
||||
public function test_membership_expiry_reminder_email(): void
|
||||
{
|
||||
$member = $this->createActiveMember([
|
||||
'membership_expires_at' => now()->addDays(30),
|
||||
]);
|
||||
|
||||
// If MembershipExpiryReminderMail exists
|
||||
// $mail = new MembershipExpiryReminderMail($member);
|
||||
// $this->assertStringContainsString('到期', $mail->render());
|
||||
|
||||
// For now, just verify member expiry date is set
|
||||
$this->assertTrue($member->membership_expires_at->diffInDays(now()) <= 30);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user