From 792387804cd9eaeef8e43678106db28210f7db4a Mon Sep 17 00:00:00 2001 From: mehrab esmailnia Date: Mon, 21 Sep 2026 05:03:07 +0330 Subject: [PATCH] feat: add BankCard and WithdrawRequest factories with comprehensive methods and tests --- database/factories/BankCardFactory.php | 21 ++ database/factories/DocumentFactory.php | 3 +- database/factories/WithdrawRequestFactory.php | 46 ++++ docs/TESTING.md | 8 +- .../Services/Invoice/InvoiceServiceTest.php | 152 +++++++++++ .../Wallet/WithdrawRequestServiceTest.php | 239 ++++++++++++++++++ 6 files changed, 464 insertions(+), 5 deletions(-) create mode 100644 database/factories/BankCardFactory.php create mode 100644 database/factories/WithdrawRequestFactory.php create mode 100644 tests/Feature/Services/Invoice/InvoiceServiceTest.php create mode 100644 tests/Feature/Services/Wallet/WithdrawRequestServiceTest.php diff --git a/database/factories/BankCardFactory.php b/database/factories/BankCardFactory.php new file mode 100644 index 0000000..5173d21 --- /dev/null +++ b/database/factories/BankCardFactory.php @@ -0,0 +1,21 @@ + + */ +class BankCardFactory extends Factory +{ + public function definition(): array + { + return [ + 'card_number' => fake()->numerify('################'), + 'holder_name' => fake()->firstName() . ' ' . fake()->lastName(), + 'user_id' => User::factory(), + ]; + } +} diff --git a/database/factories/DocumentFactory.php b/database/factories/DocumentFactory.php index d7dc8c3..7dd9228 100644 --- a/database/factories/DocumentFactory.php +++ b/database/factories/DocumentFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Enums\DocumentModalityEnum; use App\Enums\DocumentStatusEnum; use App\Enums\DocumentPriorityEnum; use App\Models\Pet; @@ -18,7 +19,7 @@ class DocumentFactory extends Factory { return [ 'code' => fake()->unique()->numberBetween(100000, 999999), - 'modality' => fake()->randomElement(['CT', 'MRI', 'X-Ray', 'Ultrasound', 'PET']), + 'modality' => fake()->randomElement(DocumentModalityEnum::cases())->value, 'status' => fake()->randomElement(DocumentStatusEnum::cases()), 'priority' => fake()->randomElement(DocumentPriorityEnum::cases()), 'note' => fake()->optional()->sentence(), diff --git a/database/factories/WithdrawRequestFactory.php b/database/factories/WithdrawRequestFactory.php new file mode 100644 index 0000000..63f4dca --- /dev/null +++ b/database/factories/WithdrawRequestFactory.php @@ -0,0 +1,46 @@ + + */ +class WithdrawRequestFactory extends Factory +{ + public function definition(): array + { + return [ + 'amount' => fake()->numberBetween(100000, 2000000), + 'status' => WithdrawRequestStatusEnum::Pending, + 'admin_note' => null, + 'reviewed_at' => null, + 'reviewed_by' => null, + 'user_id' => User::factory(), + 'bank_card_id' => fn (array $attributes) => BankCard::factory()->create(['user_id' => $attributes['user_id']])->id, + ]; + } + + public function approved(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => WithdrawRequestStatusEnum::Approved, + 'reviewed_at' => now(), + 'reviewed_by' => User::factory(), + ]); + } + + public function rejected(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => WithdrawRequestStatusEnum::Rejected, + 'admin_note' => fake()->sentence(), + 'reviewed_at' => now(), + 'reviewed_by' => User::factory(), + ]); + } +} diff --git a/docs/TESTING.md b/docs/TESTING.md index b70270a..566c528 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -102,8 +102,8 @@ Legend: `☐` not written · `☑` written · `—` intentionally not tested | Priority | Source | Test | Status | |----------|--------|------|--------| | P1 | `app/Services/Wallet/WalletService.php` | `tests/Feature/Services/Wallet/WalletServiceTest.php` | ☑ | -| P1 | `app/Services/Wallet/WithdrawRequestService.php` | `tests/Feature/Services/Wallet/WithdrawRequestServiceTest.php` | ☐ | -| P1 | `app/Services/Invoice/InvoiceService.php` | `tests/Feature/Services/Invoice/InvoiceServiceTest.php` | ☐ | +| P1 | `app/Services/Wallet/WithdrawRequestService.php` | `tests/Feature/Services/Wallet/WithdrawRequestServiceTest.php` | ☑ | +| P1 | `app/Services/Invoice/InvoiceService.php` | `tests/Feature/Services/Invoice/InvoiceServiceTest.php` | ☑ | | P1 | `app/Services/Payment/ZibalPaymentService.php` | `tests/Feature/Services/Payment/ZibalPaymentServiceTest.php` | ☐ | | P1 | `app/Services/Auth/OtpService.php` | `tests/Feature/Services/Auth/OtpServiceTest.php` | ☐ | | P1 | `app/Services/Auth/AuthService.php` | `tests/Feature/Services/Auth/AuthServiceTest.php` | ☐ | @@ -196,8 +196,8 @@ Missing — create when first needed by a test: |-------|--------| | `Wallet` | ☑ | | `WalletTransaction` | ☐ | -| `WithdrawRequest` | ☐ | -| `BankCard` | ☐ | +| `WithdrawRequest` | ☑ | +| `BankCard` | ☑ | | `Invoice` | ☑ | | `Payment` | ☑ | | `OtpCode` | ☐ | diff --git a/tests/Feature/Services/Invoice/InvoiceServiceTest.php b/tests/Feature/Services/Invoice/InvoiceServiceTest.php new file mode 100644 index 0000000..13b37fe --- /dev/null +++ b/tests/Feature/Services/Invoice/InvoiceServiceTest.php @@ -0,0 +1,152 @@ +service = app(InvoiceService::class); + } + + public function test_create_for_document_creates_pending_invoice_priced_by_modality(): void + { + $document = Document::factory()->registered()->create(['modality' => DocumentModalityEnum::Mri->value]); + + $invoice = $this->service->createForDocument($document); + + $this->assertSame(InvoiceStatusEnum::Pending, $invoice->status); + $this->assertSame(DocumentModalityEnum::Mri->price(), $invoice->amount); + $this->assertNull($invoice->payment_method); + $this->assertNull($invoice->paid_at); + $this->assertDatabaseHas('invoices', [ + 'id' => $invoice->id, + 'document_id' => $document->id, + 'user_id' => $document->requester_id, + 'amount' => 1200000, + ]); + } + + public function test_create_for_document_sets_number_as_zero_padded_id(): void + { + $document = Document::factory()->registered()->create(); + + $invoice = $this->service->createForDocument($document); + + $this->assertSame(str_pad((string) $invoice->id, 5, '0', STR_PAD_LEFT), $invoice->fresh()->number); + } + + public function test_create_for_document_generates_unique_numbers(): void + { + $first = $this->service->createForDocument(Document::factory()->registered()->create()); + $second = $this->service->createForDocument(Document::factory()->registered()->create()); + + $this->assertNotSame($first->fresh()->number, $second->fresh()->number); + } + + public function test_mark_paid_updates_invoice_status_method_and_paid_at(): void + { + Carbon::setTestNow('2026-09-21 10:00:00'); + $invoice = Invoice::factory()->create(); + + $paid = $this->service->markPaid($invoice, InvoicePaymentMethodEnum::Gateway); + + $this->assertSame(InvoiceStatusEnum::Paid, $paid->status); + $this->assertSame(InvoicePaymentMethodEnum::Gateway, $paid->payment_method); + $this->assertTrue($paid->paid_at->eq(Carbon::parse('2026-09-21 10:00:00'))); + $this->assertDatabaseHas('invoices', [ + 'id' => $invoice->id, + 'status' => InvoiceStatusEnum::Paid->value, + 'payment_method' => InvoicePaymentMethodEnum::Gateway->value, + ]); + } + + public function test_mark_paid_moves_registered_document_to_in_progress_with_deadline_by_priority(): void + { + Carbon::setTestNow('2026-09-21 10:00:00'); + $document = Document::factory()->registered()->high()->create(['processing_deadline' => null]); + $invoice = Invoice::factory()->create(['document_id' => $document->id, 'user_id' => $document->requester_id]); + + $this->service->markPaid($invoice, InvoicePaymentMethodEnum::Wallet); + + $document->refresh(); + $this->assertSame(DocumentStatusEnum::InProgress, $document->status); + $this->assertTrue($document->processing_deadline->eq(Carbon::parse('2026-09-21 13:00:00'))); + } + + public function test_mark_paid_uses_medium_priority_hours_for_deadline(): void + { + Carbon::setTestNow('2026-09-21 10:00:00'); + $document = Document::factory()->registered()->create(['priority' => DocumentPriorityEnum::Medium, 'processing_deadline' => null]); + $invoice = Invoice::factory()->create(['document_id' => $document->id, 'user_id' => $document->requester_id]); + + $this->service->markPaid($invoice, InvoicePaymentMethodEnum::Wallet); + + $this->assertTrue($document->fresh()->processing_deadline->eq(Carbon::parse('2026-09-22 10:00:00'))); + } + + public function test_mark_paid_does_not_change_document_that_is_not_registered(): void + { + $document = Document::factory()->completed()->create(); + $originalDeadline = $document->processing_deadline; + $invoice = Invoice::factory()->create(['document_id' => $document->id, 'user_id' => $document->requester_id]); + + $this->service->markPaid($invoice, InvoicePaymentMethodEnum::Wallet); + + $document->refresh(); + $this->assertSame(DocumentStatusEnum::Completed, $document->status); + $this->assertEquals($originalDeadline, $document->processing_deadline); + } + + public function test_mark_paid_works_for_invoice_without_document(): void + { + $invoice = Invoice::factory()->create(['document_id' => null]); + + $paid = $this->service->markPaid($invoice, InvoicePaymentMethodEnum::Wallet); + + $this->assertSame(InvoiceStatusEnum::Paid, $paid->status); + } + + public function test_mark_paid_dispatches_payment_confirmed_sms_to_invoice_user(): void + { + config(['services.iranpayamak.patterns.payment_confirmed' => 'pattern-code']); + $user = User::factory()->create(['phone' => '09120000000']); + $invoice = Invoice::factory()->create(['user_id' => $user->id]); + + $this->service->markPaid($invoice, InvoicePaymentMethodEnum::Wallet); + + Queue::assertPushed(SendPatternSms::class, 1); + } + + public function test_mark_paid_does_not_dispatch_sms_when_pattern_is_not_configured(): void + { + config(['services.iranpayamak.patterns.payment_confirmed' => null]); + $invoice = Invoice::factory()->create(); + + $this->service->markPaid($invoice, InvoicePaymentMethodEnum::Wallet); + + Queue::assertNothingPushed(); + } +} diff --git a/tests/Feature/Services/Wallet/WithdrawRequestServiceTest.php b/tests/Feature/Services/Wallet/WithdrawRequestServiceTest.php new file mode 100644 index 0000000..e435897 --- /dev/null +++ b/tests/Feature/Services/Wallet/WithdrawRequestServiceTest.php @@ -0,0 +1,239 @@ +service = app(WithdrawRequestService::class); + } + + public function test_request_creates_pending_withdraw_request(): void + { + $wallet = Wallet::factory()->create(['balance' => 500000]); + $bankCard = BankCard::factory()->create(['user_id' => $wallet->user_id]); + + $withdrawRequest = $this->service->request($wallet->user, 300000, $bankCard); + + $this->assertSame(WithdrawRequestStatusEnum::Pending, $withdrawRequest->status); + $this->assertDatabaseHas('withdraw_requests', [ + 'id' => $withdrawRequest->id, + 'amount' => 300000, + 'status' => WithdrawRequestStatusEnum::Pending->value, + 'user_id' => $wallet->user_id, + 'bank_card_id' => $bankCard->id, + ]); + $this->assertSame(500000, $wallet->fresh()->balance); + } + + public function test_request_allows_amount_equal_to_balance(): void + { + $wallet = Wallet::factory()->create(['balance' => 500000]); + $bankCard = BankCard::factory()->create(['user_id' => $wallet->user_id]); + + $withdrawRequest = $this->service->request($wallet->user, 500000, $bankCard); + + $this->assertDatabaseHas('withdraw_requests', ['id' => $withdrawRequest->id, 'amount' => 500000]); + } + + public function test_request_throws_when_bank_card_belongs_to_another_user(): void + { + $wallet = Wallet::factory()->create(['balance' => 500000]); + $bankCard = BankCard::factory()->create(); + + try { + $this->service->request($wallet->user, 100000, $bankCard); + $this->fail('ValidationException was not thrown'); + } catch (ValidationException $e) { + $this->assertArrayHasKey('bank_card_id', $e->errors()); + } + + $this->assertDatabaseCount('withdraw_requests', 0); + } + + public function test_request_throws_when_amount_exceeds_balance(): void + { + $wallet = Wallet::factory()->create(['balance' => 500000]); + $bankCard = BankCard::factory()->create(['user_id' => $wallet->user_id]); + + try { + $this->service->request($wallet->user, 500001, $bankCard); + $this->fail('ValidationException was not thrown'); + } catch (ValidationException $e) { + $this->assertArrayHasKey('amount', $e->errors()); + } + + $this->assertDatabaseCount('withdraw_requests', 0); + } + + public function test_request_throws_when_pending_requests_plus_amount_exceed_balance(): void + { + $wallet = Wallet::factory()->create(['balance' => 500000]); + $bankCard = BankCard::factory()->create(['user_id' => $wallet->user_id]); + WithdrawRequest::factory()->create(['user_id' => $wallet->user_id, 'bank_card_id' => $bankCard->id, 'amount' => 300000]); + + $this->expectException(ValidationException::class); + + $this->service->request($wallet->user, 200001, $bankCard); + } + + public function test_request_ignores_reviewed_requests_when_summing_pending_amount(): void + { + $wallet = Wallet::factory()->create(['balance' => 500000]); + $bankCard = BankCard::factory()->create(['user_id' => $wallet->user_id]); + WithdrawRequest::factory()->approved()->create(['user_id' => $wallet->user_id, 'bank_card_id' => $bankCard->id, 'amount' => 400000]); + WithdrawRequest::factory()->rejected()->create(['user_id' => $wallet->user_id, 'bank_card_id' => $bankCard->id, 'amount' => 400000]); + + $withdrawRequest = $this->service->request($wallet->user, 500000, $bankCard); + + $this->assertDatabaseHas('withdraw_requests', ['id' => $withdrawRequest->id, 'amount' => 500000]); + } + + public function test_request_ignores_other_users_pending_requests(): void + { + $wallet = Wallet::factory()->create(['balance' => 500000]); + $bankCard = BankCard::factory()->create(['user_id' => $wallet->user_id]); + WithdrawRequest::factory()->create(['amount' => 400000]); + + $withdrawRequest = $this->service->request($wallet->user, 500000, $bankCard); + + $this->assertDatabaseHas('withdraw_requests', ['id' => $withdrawRequest->id, 'amount' => 500000]); + } + + public function test_request_throws_when_user_has_no_wallet(): void + { + $user = User::factory()->create(); + $bankCard = BankCard::factory()->create(['user_id' => $user->id]); + + $this->expectException(ValidationException::class); + + $this->service->request($user, 1, $bankCard); + } + + public function test_approve_moves_amount_from_user_wallet_to_system_wallet(): void + { + $wallet = Wallet::factory()->create(['balance' => 500000]); + $admin = User::factory()->create(); + $withdrawRequest = WithdrawRequest::factory()->create(['user_id' => $wallet->user_id, 'amount' => 300000]); + + $approved = $this->service->approve($withdrawRequest, $admin); + + $this->assertSame(WithdrawRequestStatusEnum::Approved, $approved->status); + $this->assertSame($admin->id, $approved->reviewed_by); + $this->assertNotNull($approved->reviewed_at); + $this->assertSame(200000, $wallet->fresh()->balance); + $this->assertDatabaseHas('wallets', ['user_id' => null, 'balance' => 300000]); + $this->assertDatabaseHas('wallet_transactions', [ + 'wallet_id' => $wallet->id, + 'type' => WalletTransactionTypeEnum::Withdrawal->value, + 'reason' => WalletTransactionReasonEnum::Withdraw->value, + 'amount' => 300000, + 'balance_after' => 200000, + 'withdraw_request_id' => $withdrawRequest->id, + ]); + $this->assertDatabaseHas('wallet_transactions', [ + 'wallet_id' => Wallet::whereNull('user_id')->value('id'), + 'type' => WalletTransactionTypeEnum::Deposit->value, + 'reason' => WalletTransactionReasonEnum::Withdraw->value, + 'amount' => 300000, + 'balance_after' => 300000, + 'withdraw_request_id' => $withdrawRequest->id, + ]); + } + + public function test_approve_throws_when_request_is_not_pending(): void + { + $wallet = Wallet::factory()->create(['balance' => 500000]); + $admin = User::factory()->create(); + $withdrawRequest = WithdrawRequest::factory()->approved()->create(['user_id' => $wallet->user_id, 'amount' => 300000]); + + try { + $this->service->approve($withdrawRequest, $admin); + $this->fail('ValidationException was not thrown'); + } catch (ValidationException $e) { + $this->assertArrayHasKey('withdraw_request', $e->errors()); + } + + $this->assertSame(500000, $wallet->fresh()->balance); + $this->assertDatabaseCount('wallet_transactions', 0); + } + + public function test_approve_throws_and_rolls_back_when_balance_is_insufficient(): void + { + $wallet = Wallet::factory()->create(['balance' => 100000]); + $admin = User::factory()->create(); + $withdrawRequest = WithdrawRequest::factory()->create(['user_id' => $wallet->user_id, 'amount' => 300000]); + + $this->expectException(ValidationException::class); + + try { + $this->service->approve($withdrawRequest, $admin); + } finally { + $this->assertSame(WithdrawRequestStatusEnum::Pending, $withdrawRequest->fresh()->status); + $this->assertSame(100000, $wallet->fresh()->balance); + $this->assertDatabaseCount('wallet_transactions', 0); + $this->assertDatabaseMissing('wallets', ['user_id' => null]); + } + } + + public function test_reject_marks_request_rejected_with_note_without_touching_wallet(): void + { + $wallet = Wallet::factory()->create(['balance' => 500000]); + $admin = User::factory()->create(); + $withdrawRequest = WithdrawRequest::factory()->create(['user_id' => $wallet->user_id, 'amount' => 300000]); + + $rejected = $this->service->reject($withdrawRequest, $admin, 'اطلاعات کارت ناقص است'); + + $this->assertSame(WithdrawRequestStatusEnum::Rejected, $rejected->status); + $this->assertSame('اطلاعات کارت ناقص است', $rejected->admin_note); + $this->assertSame($admin->id, $rejected->reviewed_by); + $this->assertNotNull($rejected->reviewed_at); + $this->assertSame(500000, $wallet->fresh()->balance); + $this->assertDatabaseCount('wallet_transactions', 0); + } + + public function test_reject_accepts_null_note(): void + { + $admin = User::factory()->create(); + $withdrawRequest = WithdrawRequest::factory()->create(); + + $rejected = $this->service->reject($withdrawRequest, $admin); + + $this->assertSame(WithdrawRequestStatusEnum::Rejected, $rejected->status); + $this->assertNull($rejected->admin_note); + } + + public function test_reject_throws_when_request_is_not_pending(): void + { + $admin = User::factory()->create(); + $withdrawRequest = WithdrawRequest::factory()->rejected()->create(); + $originalNote = $withdrawRequest->admin_note; + + $this->expectException(ValidationException::class); + + try { + $this->service->reject($withdrawRequest, $admin, 'new note'); + } finally { + $this->assertSame($originalNote, $withdrawRequest->fresh()->admin_note); + } + } +}