diff --git a/app/Services/Wallet/WalletService.php b/app/Services/Wallet/WalletService.php index c6f85c8..1271629 100644 --- a/app/Services/Wallet/WalletService.php +++ b/app/Services/Wallet/WalletService.php @@ -27,12 +27,12 @@ class WalletService 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 { - 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 diff --git a/database/factories/InvoiceFactory.php b/database/factories/InvoiceFactory.php new file mode 100644 index 0000000..8b9c4bb --- /dev/null +++ b/database/factories/InvoiceFactory.php @@ -0,0 +1,44 @@ + + */ +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, + ]); + } +} diff --git a/database/factories/PaymentFactory.php b/database/factories/PaymentFactory.php new file mode 100644 index 0000000..c987eba --- /dev/null +++ b/database/factories/PaymentFactory.php @@ -0,0 +1,44 @@ + + */ +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, + ]); + } +} diff --git a/database/factories/WalletFactory.php b/database/factories/WalletFactory.php new file mode 100644 index 0000000..dcf6e57 --- /dev/null +++ b/database/factories/WalletFactory.php @@ -0,0 +1,27 @@ + + */ +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, + ]); + } +} diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..b70270a --- /dev/null +++ b/docs/TESTING.md @@ -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` | ☐ | diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index 8364a84..0000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,19 +0,0 @@ -get('/'); - - $response->assertStatus(200); - } -} diff --git a/tests/Feature/Services/Wallet/WalletServiceTest.php b/tests/Feature/Services/Wallet/WalletServiceTest.php new file mode 100644 index 0000000..3167a55 --- /dev/null +++ b/tests/Feature/Services/Wallet/WalletServiceTest.php @@ -0,0 +1,247 @@ +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); + } +} diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index 5773b0c..0000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,16 +0,0 @@ -assertTrue(true); - } -}