feat: implement wallet and invoice factories, enhance wallet service methods, and add comprehensive tests

This commit is contained in:
2026-09-21 05:01:19 +03:30
parent 45da0103c0
commit b4b1fdc288
8 changed files with 568 additions and 37 deletions
+2 -2
View File
@@ -27,12 +27,12 @@ class WalletService
public function forUser(User $user): Wallet public function forUser(User $user): Wallet
{ {
return Wallet::firstOrCreate(['user_id' => $user->id]); return Wallet::firstOrCreate(['user_id' => $user->id], ['balance' => 0]);
} }
public function system(): Wallet public function system(): Wallet
{ {
return Wallet::firstOrCreate(['user_id' => null]); return Wallet::firstOrCreate(['user_id' => null], ['balance' => 0]);
} }
public function deposit(User $user, int $amount, WalletTransactionReasonEnum $reason, ?Payment $payment = null): WalletTransaction public function deposit(User $user, int $amount, WalletTransactionReasonEnum $reason, ?Payment $payment = null): WalletTransaction
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace Database\Factories;
use App\Enums\DocumentModalityEnum;
use App\Enums\InvoicePaymentMethodEnum;
use App\Enums\InvoiceStatusEnum;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Invoice>
*/
class InvoiceFactory extends Factory
{
public function definition(): array
{
return [
'number' => fake()->unique()->numerify('#####'),
'amount' => fake()->randomElement(DocumentModalityEnum::cases())->price(),
'status' => InvoiceStatusEnum::Pending,
'payment_method' => null,
'paid_at' => null,
'document_id' => null,
'user_id' => User::factory(),
];
}
public function paid(InvoicePaymentMethodEnum $method = InvoicePaymentMethodEnum::Wallet): static
{
return $this->state(fn (array $attributes) => [
'status' => InvoiceStatusEnum::Paid,
'payment_method' => $method,
'paid_at' => now(),
]);
}
public function canceled(): static
{
return $this->state(fn (array $attributes) => [
'status' => InvoiceStatusEnum::Canceled,
]);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace Database\Factories;
use App\Enums\PaymentPurposeEnum;
use App\Enums\PaymentStatusEnum;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Payment>
*/
class PaymentFactory extends Factory
{
public function definition(): array
{
return [
'track_id' => fake()->unique()->numerify('##########'),
'amount' => fake()->numberBetween(100000, 2000000),
'status' => PaymentStatusEnum::Pending,
'purpose' => PaymentPurposeEnum::Invoice,
'ref_number' => null,
'verified_at' => null,
'invoice_id' => null,
'payer_id' => User::factory(),
];
}
public function paid(): static
{
return $this->state(fn (array $attributes) => [
'status' => PaymentStatusEnum::Paid,
'ref_number' => fake()->numerify('############'),
'verified_at' => now(),
]);
}
public function walletTopUp(): static
{
return $this->state(fn (array $attributes) => [
'purpose' => PaymentPurposeEnum::WalletTopUp,
]);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Wallet>
*/
class WalletFactory extends Factory
{
public function definition(): array
{
return [
'balance' => 0,
'user_id' => User::factory(),
];
}
public function system(): static
{
return $this->state(fn (array $attributes) => [
'user_id' => null,
]);
}
}
+204
View File
@@ -0,0 +1,204 @@
# Testing Guide
Tests are written **only on explicit request**. When asked, follow this document exactly and keep the
coverage matrix at the bottom up to date so remaining gaps are always visible.
## 1. What we test (and what we don't)
Business logic lives in `app/Services/`. Models are mostly relations and `$fillable`. Testing is
prioritised accordingly:
| Priority | Layer | Why |
|----------|-------|-----|
| P1 | Services | Money, auth, scheduling, case flow — the only place real logic lives |
| P1 | Form Requests | Cheap, catches every invalid input path before it reaches a service |
| P1 | HTTP (critical routes) | Authentication, authorization/ownership, payment callback |
| P2 | Remaining HTTP | Doctor & Admin CRUD |
| P3 | Enums | Only methods with logic (`price()`, `processingHours()`, `fromDate()`, …) |
| P3 | Models | Only scopes, accessors and helper methods — never relations or `$fillable` |
**Never test:** Eloquent relations, `$fillable`/`$casts`, framework behaviour, API Resources on their
own (they are asserted through HTTP tests), Vue components (no JS test stack in this project).
## 2. Directory structure — one source file ↔ one test file
Test files mirror the source path. This is what makes gap detection mechanical.
```
app/Services/Wallet/WalletService.php → tests/Feature/Services/Wallet/WalletServiceTest.php
app/Http/Requests/Client/PaymentRequest.php → tests/Feature/Requests/Client/PaymentRequestTest.php
app/Http/Controllers/Client/PaymentController.php → tests/Feature/Http/Client/PaymentControllerTest.php
app/Enums/ShiftDayEnum.php → tests/Unit/Enums/ShiftDayEnumTest.php
app/Models/Shift.php → tests/Feature/Models/ShiftTest.php
```
- `tests/Unit/` — no database, no facades. Enums only.
- `tests/Feature/` — boots the app, uses `RefreshDatabase` (sqlite `:memory:`, already configured in `phpunit.xml`).
- The default `ExampleTest` placeholders have been removed; never re-add them.
## 3. Conventions
- PHPUnit 11, class-based tests extending `Tests\TestCase`, `RefreshDatabase` trait on every Feature test.
- Method names: `test_{action}_{expected}_when_{condition}` in snake_case, e.g.
`test_deposit_increases_balance`, `test_withdraw_throws_when_balance_is_insufficient`.
- One behaviour per test. Arrange / Act / Assert, separated by a blank line, no comments.
- Resolve services from the container (`app(WalletService::class)`), never `new`.
- Every model touched by a test must have a factory in `database/factories/`. Create the missing factory
first, in the same style as the existing ones. Factories must produce valid rows with all foreign keys.
- Assert outcomes, not implementation: `assertDatabaseHas`, returned values, thrown exceptions,
`assertInertia` props / JSON structure for HTTP.
- Use enums in assertions (`DocumentStatusEnum::Pending`), never raw strings.
### Fakes — never hit a real external system
| Dependency | Fake |
|------------|------|
| Zibal (`ZibalPaymentService`), IranPayamak (`IranPayamakService`) | `Http::fake([...])` with `Http::assertSent` |
| `SendPatternSms` job / `SmsNotificationService` | `Queue::fake()` + `Queue::assertPushed(SendPatternSms::class)` |
| `FileStorageService`, `Medium` | `Storage::fake('public')`, `Storage::fake('private')` |
| Time-dependent logic (shifts, OTP expiry, processing hours) | `Carbon::setTestNow(...)` |
| Authenticated user | `$this->actingAs(User::factory()->create())` |
| Spatie roles/permissions | seed `PermissionSeeder` in the test, then `$user->givePermissionTo(...)` / `assignRole(...)` |
## 4. Required coverage per layer
**Service** — happy path; every guard / exception branch; every side effect (DB rows, dispatched
jobs, transactions written); for money code, balance invariants before and after.
**Form Request** — `authorize()` when it is not `return true`; each rule with an invalid input →
`assertSessionHasErrors('field')` / 422; one fully valid payload passes.
**HTTP** — guest is redirected to login; wrong role or non-owner gets 403/404; happy path returns
the expected status and persisted state; Inertia page tests assert the component name and key props.
**Enum** — every method containing logic, including edge values.
**Model** — each scope filters correctly; each accessor / helper method (`isExpired`, `fullUrl`, `humanSize`, …).
## 5. Workflow when asked to write tests
1. Read this file. If the user names a target, test that; otherwise take the next `☐` row by priority (P1 → P3).
2. Create missing factories first.
3. Write the test file at the mirrored path (section 2).
4. Run `php artisan test --filter={ClassName}` and make it green. Then run the full suite.
5. Flip the row to `☑` in the matrix below. Add rows for any new Service / Request / Controller / Enum that exists in the code but is missing from the matrix.
6. Do not modify unrelated tests or source files.
### Detecting gaps mechanically
The matrix is the source of truth, but this catches rows that were never added. Anything it lists
that is not in the matrix must be added as `☐` or, if it has no logic worth testing, as `—`:
```bash
for f in $(find app/Services app/Http/Requests app/Http/Controllers app/Enums -name '*.php' ! -name 'Controller.php' ! -name 'EnumHasOptions.php'); do t=$(echo "$f" | sed -E 's#^app/Services/#tests/Feature/Services/#; s#^app/Http/Requests/#tests/Feature/Requests/#; s#^app/Http/Controllers/#tests/Feature/Http/#; s#^app/Enums/#tests/Unit/Enums/#; s#\.php$#Test.php#'); [ -f "$t" ] || echo "MISSING $t"; done
```
## 6. Coverage matrix
Legend: `☐` not written · `☑` written · `—` intentionally not tested
### Services
| 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/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` | ☐ |
| P1 | `app/Services/Auth/RegistrationService.php` | `tests/Feature/Services/Auth/RegistrationServiceTest.php` | ☐ |
| P1 | `app/Services/Shift/ShiftScheduleService.php` | `tests/Feature/Services/Shift/ShiftScheduleServiceTest.php` | ☐ |
| P1 | `app/Services/Document/CaseService.php` | `tests/Feature/Services/Document/CaseServiceTest.php` | ☐ |
| P1 | `app/Services/Document/FastRequestService.php` | `tests/Feature/Services/Document/FastRequestServiceTest.php` | ☐ |
| P2 | `app/Services/Document/DocumentCodeService.php` | `tests/Feature/Services/Document/DocumentCodeServiceTest.php` | ☐ |
| P2 | `app/Services/Sms/SmsNotificationService.php` | `tests/Feature/Services/Sms/SmsNotificationServiceTest.php` | ☐ |
| P2 | `app/Services/FileStorageService.php` | `tests/Feature/Services/FileStorageServiceTest.php` | ☐ |
| P3 | `app/Services/Sms/IranPayamakService.php` | `tests/Feature/Services/Sms/IranPayamakServiceTest.php` | ☐ |
### Form Requests
| Priority | Source | Test | Status |
|----------|--------|------|--------|
| P1 | `app/Http/Requests/Client/Auth/UserRegistrationRequest.php` | `tests/Feature/Requests/Client/Auth/UserRegistrationRequestTest.php` | ☐ |
| P1 | `app/Http/Requests/Client/Auth/VerifyOtpRequest.php` | `tests/Feature/Requests/Client/Auth/VerifyOtpRequestTest.php` | ☐ |
| P1 | `app/Http/Requests/Client/PaymentRequest.php` | `tests/Feature/Requests/Client/PaymentRequestTest.php` | ☐ |
| P1 | `app/Http/Requests/Client/WalletDepositRequest.php` | `tests/Feature/Requests/Client/WalletDepositRequestTest.php` | ☐ |
| P1 | `app/Http/Requests/Client/WithdrawRequestStoreRequest.php` | `tests/Feature/Requests/Client/WithdrawRequestStoreRequestTest.php` | ☐ |
| P1 | `app/Http/Requests/Client/BankCardStoreRequest.php` | `tests/Feature/Requests/Client/BankCardStoreRequestTest.php` | ☐ |
| P1 | `app/Http/Requests/Client/FastRequestRequest.php` | `tests/Feature/Requests/Client/FastRequestRequestTest.php` | ☐ |
| P2 | `app/Http/Requests/Client/CaseRejectRequest.php` | `tests/Feature/Requests/Client/CaseRejectRequestTest.php` | ☐ |
| P2 | `app/Http/Requests/Client/ReportSubmitRequest.php` | `tests/Feature/Requests/Client/ReportSubmitRequestTest.php` | ☐ |
| P2 | `app/Http/Requests/Client/ProfileUpdateRequest.php` | `tests/Feature/Requests/Client/ProfileUpdateRequestTest.php` | ☐ |
| P2 | `app/Http/Requests/Admin/WithdrawRejectRequest.php` | `tests/Feature/Requests/Admin/WithdrawRejectRequestTest.php` | ☐ |
| P2 | `app/Http/Requests/Admin/DocumentRequest.php` | `tests/Feature/Requests/Admin/DocumentRequestTest.php` | ☐ |
| P2 | `app/Http/Requests/Admin/ShiftRequest.php` | `tests/Feature/Requests/Admin/ShiftRequestTest.php` | ☐ |
| P2 | `app/Http/Requests/Admin/UserRequest.php` | `tests/Feature/Requests/Admin/UserRequestTest.php` | ☐ |
| P2 | `app/Http/Requests/Admin/PetRequest.php` | `tests/Feature/Requests/Admin/PetRequestTest.php` | ☐ |
| P2 | `app/Http/Requests/Admin/ReportRequest.php` | `tests/Feature/Requests/Admin/ReportRequestTest.php` | ☐ |
| P2 | `app/Http/Requests/Admin/SettingProfileRequest.php` | `tests/Feature/Requests/Admin/SettingProfileRequestTest.php` | ☐ |
### HTTP
| Priority | Source | Test | Status |
|----------|--------|------|--------|
| P1 | `app/Http/Controllers/Client/AuthController.php` | `tests/Feature/Http/Client/AuthControllerTest.php` | ☐ |
| P1 | `app/Http/Controllers/Client/PaymentController.php` | `tests/Feature/Http/Client/PaymentControllerTest.php` | ☐ |
| P1 | `app/Http/Controllers/Client/WalletController.php` | `tests/Feature/Http/Client/WalletControllerTest.php` | ☐ |
| P1 | `app/Http/Controllers/Client/FastRequestController.php` | `tests/Feature/Http/Client/FastRequestControllerTest.php` | ☐ |
| P1 | `app/Http/Controllers/Client/InvoiceController.php` | `tests/Feature/Http/Client/InvoiceControllerTest.php` | ☐ |
| P1 | `app/Http/Controllers/Admin/WithdrawRequestController.php` | `tests/Feature/Http/Admin/WithdrawRequestControllerTest.php` | ☐ |
| P2 | `app/Http/Controllers/Client/Doctor/CaseController.php` | `tests/Feature/Http/Client/Doctor/CaseControllerTest.php` | ☐ |
| P2 | `app/Http/Controllers/Client/Doctor/ReportController.php` | `tests/Feature/Http/Client/Doctor/ReportControllerTest.php` | ☐ |
| P2 | `app/Http/Controllers/Client/Doctor/ShiftController.php` | `tests/Feature/Http/Client/Doctor/ShiftControllerTest.php` | ☐ |
| P2 | `app/Http/Controllers/Client/Doctor/DocumentController.php` | `tests/Feature/Http/Client/Doctor/DocumentControllerTest.php` | ☐ |
| P2 | `app/Http/Controllers/Client/Doctor/DashboardController.php` | `tests/Feature/Http/Client/Doctor/DashboardControllerTest.php` | ☐ |
| P2 | `app/Http/Controllers/Client/MediaController.php` | `tests/Feature/Http/Client/MediaControllerTest.php` | ☐ |
| P2 | `app/Http/Controllers/Client/ProfileController.php` | `tests/Feature/Http/Client/ProfileControllerTest.php` | ☐ |
| P2 | `app/Http/Controllers/Admin/AuthController.php` | `tests/Feature/Http/Admin/AuthControllerTest.php` | ☐ |
| P2 | `app/Http/Controllers/Admin/DocumentController.php` | `tests/Feature/Http/Admin/DocumentControllerTest.php` | ☐ |
| P2 | `app/Http/Controllers/Admin/ShiftController.php` | `tests/Feature/Http/Admin/ShiftControllerTest.php` | ☐ |
| P2 | `app/Http/Controllers/Admin/UserController.php` | `tests/Feature/Http/Admin/UserControllerTest.php` | ☐ |
| P2 | `app/Http/Controllers/Admin/ReportController.php` | `tests/Feature/Http/Admin/ReportControllerTest.php` | ☐ |
| P2 | `app/Http/Controllers/Admin/PetController.php` | `tests/Feature/Http/Admin/PetControllerTest.php` | ☐ |
| P2 | `app/Http/Controllers/Admin/MediumController.php` | `tests/Feature/Http/Admin/MediumControllerTest.php` | ☐ |
| P2 | `app/Http/Controllers/Admin/SettingUserController.php` | `tests/Feature/Http/Admin/SettingUserControllerTest.php` | ☐ |
| P3 | `app/Http/Controllers/Admin/Role/RoleController.php` | `tests/Feature/Http/Admin/Role/RoleControllerTest.php` | ☐ |
| P3 | `app/Http/Controllers/Admin/Role/PermissionController.php` | `tests/Feature/Http/Admin/Role/PermissionControllerTest.php` | ☐ |
| P3 | `app/Http/Controllers/Admin/PanelController.php` | `tests/Feature/Http/Admin/PanelControllerTest.php` | ☐ |
### Enums
| Priority | Source | Test | Status |
|----------|--------|------|--------|
| P3 | `app/Enums/ShiftDayEnum.php` | `tests/Unit/Enums/ShiftDayEnumTest.php` | ☐ |
| P3 | `app/Enums/DocumentModalityEnum.php` | `tests/Unit/Enums/DocumentModalityEnumTest.php` | ☐ |
| P3 | `app/Enums/DocumentPriorityEnum.php` | `tests/Unit/Enums/DocumentPriorityEnumTest.php` | ☐ |
| P3 | `app/Enums/EnumHasOptions.php` (via any enum using it) | `tests/Unit/Enums/EnumHasOptionsTest.php` | ☐ |
| — | all other enums (values only) | | — |
### Models
| Priority | Source | Test | Status |
|----------|--------|------|--------|
| P3 | `app/Models/Shift.php` (`scopeActive`, `scopeOpen`) | `tests/Feature/Models/ShiftTest.php` | ☐ |
| P3 | `app/Models/Medium.php` (`fullUrl`, `humanSize`, `exists`, `isPublic`, `isPrivate`, `deleteFile`) | `tests/Feature/Models/MediumTest.php` | ☐ |
| P3 | `app/Models/OtpCode.php` (`isExpired`) | `tests/Feature/Models/OtpCodeTest.php` | ☐ |
| — | all other models (relations / fillable only) | | — |
### Factories
Existing: `User`, `Pet`, `Document`, `DocumentAssignment`, `Medium`, `MediaAssignment`, `Report`.
Missing — create when first needed by a test:
| Model | Status |
|-------|--------|
| `Wallet` | ☑ |
| `WalletTransaction` | ☐ |
| `WithdrawRequest` | ☐ |
| `BankCard` | ☐ |
| `Invoice` | ☑ |
| `Payment` | ☑ |
| `OtpCode` | ☐ |
| `Shift` | ☐ |
-19
View File
@@ -1,19 +0,0 @@
<?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);
}
}
@@ -0,0 +1,247 @@
<?php
namespace Tests\Feature\Services\Wallet;
use App\Enums\InvoicePaymentMethodEnum;
use App\Enums\InvoiceStatusEnum;
use App\Enums\WalletTransactionReasonEnum;
use App\Enums\WalletTransactionTypeEnum;
use App\Jobs\SendPatternSms;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\User;
use App\Models\Wallet;
use App\Models\WalletTransaction;
use App\Services\Wallet\WalletService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
class WalletServiceTest extends TestCase
{
use RefreshDatabase;
protected WalletService $service;
protected function setUp(): void
{
parent::setUp();
Queue::fake();
$this->service = app(WalletService::class);
}
public function test_for_user_creates_wallet_when_user_has_none(): void
{
$user = User::factory()->create();
$wallet = $this->service->forUser($user);
$this->assertSame($user->id, $wallet->user_id);
$this->assertSame(0, $wallet->balance);
$this->assertDatabaseCount('wallets', 1);
}
public function test_for_user_returns_existing_wallet(): void
{
$wallet = Wallet::factory()->create(['balance' => 5000]);
$found = $this->service->forUser($wallet->user);
$this->assertTrue($found->is($wallet));
$this->assertSame(5000, $found->balance);
$this->assertDatabaseCount('wallets', 1);
}
public function test_system_returns_single_wallet_without_user(): void
{
$first = $this->service->system();
$second = $this->service->system();
$this->assertNull($first->user_id);
$this->assertTrue($first->is($second));
$this->assertDatabaseCount('wallets', 1);
}
public function test_deposit_increases_balance_and_records_transaction(): void
{
$wallet = Wallet::factory()->create(['balance' => 1000]);
$payment = Payment::factory()->paid()->walletTopUp()->create(['payer_id' => $wallet->user_id, 'amount' => 2500]);
$transaction = $this->service->deposit($wallet->user, 2500, WalletTransactionReasonEnum::TopUp, $payment);
$this->assertSame(3500, $wallet->fresh()->balance);
$this->assertDatabaseHas('wallet_transactions', [
'id' => $transaction->id,
'wallet_id' => $wallet->id,
'type' => WalletTransactionTypeEnum::Deposit->value,
'reason' => WalletTransactionReasonEnum::TopUp->value,
'amount' => 2500,
'balance_after' => 3500,
'payment_id' => $payment->id,
'invoice_id' => null,
'withdraw_request_id' => null,
]);
}
public function test_deposit_creates_wallet_when_user_has_none(): void
{
$user = User::factory()->create();
$this->service->deposit($user, 700, WalletTransactionReasonEnum::Commission);
$this->assertDatabaseHas('wallets', ['user_id' => $user->id, 'balance' => 700]);
}
public function test_deposit_to_wallet_works_on_system_wallet(): void
{
$wallet = Wallet::factory()->system()->create(['balance' => 100]);
$transaction = $this->service->depositToWallet($wallet, 400, WalletTransactionReasonEnum::Commission);
$this->assertSame(500, $wallet->fresh()->balance);
$this->assertSame($wallet->id, $transaction->wallet_id);
$this->assertSame(500, $transaction->balance_after);
}
public function test_withdraw_decreases_balance_and_records_transaction(): void
{
$wallet = Wallet::factory()->create(['balance' => 5000]);
$invoice = Invoice::factory()->create(['user_id' => $wallet->user_id, 'amount' => 3000]);
$transaction = $this->service->withdraw($wallet->user, 3000, WalletTransactionReasonEnum::InvoicePayment, $invoice);
$this->assertSame(2000, $wallet->fresh()->balance);
$this->assertDatabaseHas('wallet_transactions', [
'id' => $transaction->id,
'wallet_id' => $wallet->id,
'type' => WalletTransactionTypeEnum::Withdrawal->value,
'reason' => WalletTransactionReasonEnum::InvoicePayment->value,
'amount' => 3000,
'balance_after' => 2000,
'invoice_id' => $invoice->id,
'payment_id' => null,
]);
}
public function test_withdraw_allows_spending_entire_balance(): void
{
$wallet = Wallet::factory()->create(['balance' => 1000]);
$this->service->withdraw($wallet->user, 1000, WalletTransactionReasonEnum::Withdraw);
$this->assertSame(0, $wallet->fresh()->balance);
}
public function test_withdraw_throws_when_balance_is_insufficient(): void
{
$wallet = Wallet::factory()->create(['balance' => 1000]);
try {
$this->service->withdraw($wallet->user, 1001, WalletTransactionReasonEnum::Withdraw);
$this->fail('ValidationException was not thrown');
} catch (ValidationException $e) {
$this->assertArrayHasKey('amount', $e->errors());
}
$this->assertSame(1000, $wallet->fresh()->balance);
$this->assertDatabaseCount('wallet_transactions', 0);
}
public function test_pay_invoice_withdraws_amount_and_marks_invoice_paid(): void
{
$wallet = Wallet::factory()->create(['balance' => 2000000]);
$invoice = Invoice::factory()->create(['user_id' => $wallet->user_id, 'amount' => 1200000]);
$paid = $this->service->payInvoice($wallet->user, $invoice);
$this->assertSame(InvoiceStatusEnum::Paid, $paid->status);
$this->assertSame(InvoicePaymentMethodEnum::Wallet, $paid->payment_method);
$this->assertNotNull($paid->paid_at);
$this->assertSame(800000, $wallet->fresh()->balance);
$this->assertDatabaseHas('wallet_transactions', [
'wallet_id' => $wallet->id,
'type' => WalletTransactionTypeEnum::Withdrawal->value,
'reason' => WalletTransactionReasonEnum::InvoicePayment->value,
'amount' => 1200000,
'invoice_id' => $invoice->id,
]);
}
public function test_pay_invoice_dispatches_payment_confirmed_sms(): void
{
config(['services.iranpayamak.patterns.payment_confirmed' => 'pattern-code']);
$wallet = Wallet::factory()->create(['balance' => 500000]);
$invoice = Invoice::factory()->create(['user_id' => $wallet->user_id, 'amount' => 400000]);
$this->service->payInvoice($wallet->user, $invoice);
Queue::assertPushed(SendPatternSms::class);
}
public function test_pay_invoice_throws_when_invoice_is_not_pending(): void
{
$wallet = Wallet::factory()->create(['balance' => 2000000]);
$invoice = Invoice::factory()->paid()->create(['user_id' => $wallet->user_id, 'amount' => 1000]);
try {
$this->service->payInvoice($wallet->user, $invoice);
$this->fail('ValidationException was not thrown');
} catch (ValidationException $e) {
$this->assertArrayHasKey('invoice', $e->errors());
}
$this->assertSame(2000000, $wallet->fresh()->balance);
$this->assertDatabaseCount('wallet_transactions', 0);
}
public function test_pay_invoice_throws_and_keeps_invoice_pending_when_balance_is_insufficient(): void
{
$wallet = Wallet::factory()->create(['balance' => 1000]);
$invoice = Invoice::factory()->create(['user_id' => $wallet->user_id, 'amount' => 1200000]);
$this->expectException(ValidationException::class);
try {
$this->service->payInvoice($wallet->user, $invoice);
} finally {
$this->assertSame(InvoiceStatusEnum::Pending, $invoice->fresh()->status);
$this->assertSame(1000, $wallet->fresh()->balance);
Queue::assertNothingPushed();
}
}
public function test_record_gateway_invoice_payment_creates_transaction_without_changing_balance(): void
{
$wallet = Wallet::factory()->create(['balance' => 9000]);
$invoice = Invoice::factory()->create(['user_id' => $wallet->user_id, 'amount' => 700000]);
$payment = Payment::factory()->paid()->create(['payer_id' => $wallet->user_id, 'invoice_id' => $invoice->id, 'amount' => 700000]);
$transaction = $this->service->recordGatewayInvoicePayment($invoice, $payment);
$this->assertSame(9000, $wallet->fresh()->balance);
$this->assertDatabaseHas('wallet_transactions', [
'id' => $transaction->id,
'wallet_id' => $wallet->id,
'type' => WalletTransactionTypeEnum::Withdrawal->value,
'reason' => WalletTransactionReasonEnum::InvoicePayment->value,
'amount' => 700000,
'balance_after' => 9000,
'invoice_id' => $invoice->id,
'payment_id' => $payment->id,
]);
}
public function test_record_gateway_invoice_payment_creates_wallet_when_user_has_none(): void
{
$invoice = Invoice::factory()->create(['amount' => 500000]);
$payment = Payment::factory()->paid()->create(['payer_id' => $invoice->user_id, 'invoice_id' => $invoice->id]);
$transaction = $this->service->recordGatewayInvoicePayment($invoice, $payment);
$this->assertDatabaseHas('wallets', ['user_id' => $invoice->user_id, 'balance' => 0]);
$this->assertSame(0, $transaction->balance_after);
$this->assertInstanceOf(WalletTransaction::class, $transaction);
}
}
-16
View File
@@ -1,16 +0,0 @@
<?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);
}
}