feat: implement wallet and invoice factories, enhance wallet service methods, and add comprehensive tests
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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` | ☐ |
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user