Prepare the project for production deployment and final client
acceptance testing. Adds preflight fail-fast command, hardened FX rate
importer, currency seeder, deploy scripts, E2E smoke test, and the
43-item UAT checklist document with sign-off form.
Key changes:
- Phase 3.9.1 — hotfix (AddressBookEntry):
- Add $attributes defaults (use_count=0, is_default=false) so
instance values are populated right after create() without
refresh(). Resolves the red test
`it_increments_use_count_and_sets_last_used_at`.
- Phase 3.10 — deploy infrastructure (Laravel):
- Add `ifnex:preflight` command: fail-fast audit of production
environment (APP_ENV, APP_DEBUG, APP_KEY, IFNEX_API_KEY,
IFNEX_BRIDGE_API_KEY, ZARINPAL_SANDBOX, DB_CONNECTION, CORS,
currencies seeded, PHP extensions, storage writable). Exits 1
if any check fails; supports --skip-db for pre-migrate runs.
- Add `ifnex:import-fx-rates` command: replacement for
`ifnex:update-rates`. Fetches USD/AED from Exir.io + EUR/CNY
from FreeCurrencyAPI. Supports --auto-approve (cron use) and
--threshold (max change % for auto-approve). Logs critical on
HTTP failure. URL configurable via EXIR_API_URL env.
- Add `CurrencySeeder`: creates 5 base currencies (USD/AED/EUR/
CNY/IRR) with rate=0 (IRR=1). Idempotent — does not overwrite
real rates already set by admin or cron.
- Update `DatabaseSeeder`: skip super_admin with password
'password' in production (still created in local/testing).
- Fix `bootstrap/app.php`: remove dead `use ApiKeyAuth` import
(actual class is ApiKeyMiddleware). Latent fatal if api_key
alias was used.
- Add `config/ifnex.php` exir block + preflight config.
- Harden `.env.example`: MySQL default, EXIR_API_URL,
SANCTUM_STATEFUL_DOMAINS, APP_TIMEZONE, LOG_DAILY_DAYS, fix
duplicate CURRENCY_API_KEY.
- Phase 3.10 — deploy scripts (Linux):
- Add `scripts/deploy.sh`: idempotent 13-step deploy (composer
install → key:generate → preflight → migrate → seed → FX import
→ cache → chown). Fail-fast with set -e.
- Add `scripts/cron-exir-rates.sh`: daily cron wrapper for
`ifnex:import-fx-rates --source=all --auto-approve`.
- Phase 3.10.1 — preflight hotfix:
- Make expected DB connection configurable
(ifnex.preflight.expected_db_connection) instead of hardcoded
'mysql'. Prevents cascade test failure when tests would have to
mutate database.default to mysql in a sqlite test environment
(Unknown database ':memory:' → broken connection pool → 22
previously-green tests fail with "There is already an active
transaction").
- Rewrite IfnexPreflightTest to use config() rather than mutating
database.default.
- Phase 3.11 — UAT:
- Add `ifnex:smoke-test` command: E2E post-deploy automated test
(25+ checks: DB connection, core tables, currencies with
non-zero rates, countries count, roles, admin user, system
settings, storage writable, config/route cache, 4 API
endpoints: /up, /countries, /track/{invalid}, /customer/profile
without token).
- Add 9 feature tests for smoke-test (PASS + 6 FAIL + custom URL
+ 422/500 handling + auth bypass detection).
- Add `UAT-3.11.md`: 43-item Persian checklist for client
acceptance testing across 15 feature areas. Includes sign-off
form for both parties (client + VernaSoft).
- Update `CLIENT_DELIVERY.md` to record phases 3.10 and 3.11.
Tests:
- Total: 51 → 82 tests, 150 → 220+ assertions, all green.
- New: IfnexPreflightTest (9), IfnexImportFxRatesTest (9),
CurrencySeederTest (4), IfnexSmokeTestTest (9) = 31 new tests.
Documentation:
- DEPLOYMENT_HANDOFF.md §2.4: add CurrencySeeder + import-fx-rates.
- DEPLOYMENT_HANDOFF.md §2.8: update cron to use cron-exir-rates.sh.
- UAT-3.11.md: new 43-item client acceptance checklist + sign-off.
- CLIENT_DELIVERY.md: record phases 3.10 and 3.11 completion.
244 lines
8.4 KiB
PHP
244 lines
8.4 KiB
PHP
<?php
|
||
|
||
namespace Tests\Feature\Console;
|
||
|
||
use App\Models\Currency;
|
||
use App\Models\ExchangeRateHistory;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Support\Facades\Http;
|
||
use PHPUnit\Framework\Attributes\Test;
|
||
use Tests\TestCase;
|
||
|
||
/**
|
||
* تستهای دستور ifnex:import-fx-rates (فاز ۳.۱۰)
|
||
*
|
||
* این تستها با Http::fake کار میکنند تا بدون اتصال واقعی به Exir.io
|
||
* یا FreeCurrencyAPI قابل اجرا باشند.
|
||
*/
|
||
class IfnexImportFxRatesTest extends TestCase
|
||
{
|
||
use RefreshDatabase;
|
||
|
||
private function seedCurrencies(): void
|
||
{
|
||
foreach (['IRR', 'USD', 'AED', 'EUR', 'CNY'] as $code) {
|
||
Currency::create([
|
||
'code' => $code,
|
||
'name' => $code,
|
||
'symbol' => $code,
|
||
'rate_to_rial' => $code === 'IRR' ? 1 : 0, // غیر IRR صفر → اولین import باید بنویسد
|
||
'is_active' => true,
|
||
]);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════ موفقیت ها ═══════════════════
|
||
|
||
#[Test]
|
||
public function it_fetches_usd_and_aed_from_exir_with_auto_approve()
|
||
{
|
||
$this->seedCurrencies();
|
||
|
||
Http::fake([
|
||
'https://api.exir.io/*' => Http::response([
|
||
'usdt-rls' => ['price' => 60000.0], // 1 دلار = ۶۰,۰۰۰ تومان
|
||
'usdt-aed' => ['price' => 0.27], // 1 درهم = 0.27 تتر → 0.27 × 60000 = 16200 ریال
|
||
], 200),
|
||
]);
|
||
|
||
$this->artisan('ifnex:import-fx-rates', ['--source' => 'exir', '--auto-approve' => true])
|
||
->assertSuccessful();
|
||
|
||
$this->assertDatabaseHas('currencies', ['code' => 'USD', 'rate_to_rial' => 60000.000]);
|
||
$this->assertDatabaseHas('currencies', ['code' => 'AED', 'rate_to_rial' => 16200.000]);
|
||
|
||
// باید رکورد approved در تاریخچه ساخته شود
|
||
$this->assertDatabaseHas('exchange_rate_history', [
|
||
'currency_from' => 'USD',
|
||
'status' => 'approved',
|
||
'api_source' => 'exir.io',
|
||
]);
|
||
}
|
||
|
||
#[Test]
|
||
public function it_creates_pending_when_change_exceeds_auto_approve_threshold()
|
||
{
|
||
$this->seedCurrencies();
|
||
|
||
// USD قبلاً ۶۰,۰۰۰ بوده؛ حالا ۸۰,۰۰۰ → تغییر ۳۳٪ > threshold 5٪
|
||
Currency::where('code', 'USD')->update(['rate_to_rial' => 60000]);
|
||
Currency::where('code', 'AED')->update(['rate_to_rial' => 16200]);
|
||
|
||
Http::fake([
|
||
'https://api.exir.io/*' => Http::response([
|
||
'usdt-rls' => ['price' => 80000.0], // ۳۳٪ تغییر
|
||
'usdt-aed' => ['price' => 0.30],
|
||
], 200),
|
||
]);
|
||
|
||
$this->artisan('ifnex:import-fx-rates', [
|
||
'--source' => 'exir',
|
||
'--auto-approve' => true,
|
||
'--threshold' => 5,
|
||
])->assertSuccessful();
|
||
|
||
// باید pending بسازد چون تغییر > threshold
|
||
$this->assertDatabaseHas('exchange_rate_history', [
|
||
'currency_from' => 'USD',
|
||
'status' => 'pending',
|
||
]);
|
||
|
||
// نرخ currencies نباید تغییر کرده باشد (چون pending است)
|
||
$this->assertSame('60000.0000', Currency::where('code', 'USD')->value('rate_to_rial'));
|
||
}
|
||
|
||
#[Test]
|
||
public function it_creates_pending_by_default_without_auto_approve()
|
||
{
|
||
$this->seedCurrencies();
|
||
Currency::where('code', 'USD')->update(['rate_to_rial' => 60000]);
|
||
|
||
Http::fake([
|
||
'https://api.exir.io/*' => Http::response([
|
||
'usdt-rls' => ['price' => 90000.0], // ۵۰٪ تغییر
|
||
], 200),
|
||
]);
|
||
|
||
$this->artisan('ifnex:import-fx-rates', ['--source' => 'exir'])
|
||
->assertSuccessful();
|
||
|
||
// بدون --auto-approve همیشه pending میسازد
|
||
$this->assertDatabaseHas('exchange_rate_history', [
|
||
'currency_from' => 'USD',
|
||
'status' => 'pending',
|
||
]);
|
||
|
||
// نرخ currencies نباید تغییر کند
|
||
$this->assertSame('60000.0000', Currency::where('code', 'USD')->value('rate_to_rial'));
|
||
}
|
||
|
||
#[Test]
|
||
public function it_skips_when_change_is_below_threshold()
|
||
{
|
||
$this->seedCurrencies();
|
||
Currency::where('code', 'USD')->update(['rate_to_rial' => 60000]);
|
||
|
||
// تغییر فقط ۱٪ < 0.5٪ threshold؟ نه — threshold پیشفرض 0.5٪، پس ۱٪ باید رکورد بسازد
|
||
// برای تست skip، تغییر بسیار کوچک میزنیم
|
||
Http::fake([
|
||
'https://api.exir.io/*' => Http::response([
|
||
'usdt-rls' => ['price' => 60100.0], // ۰.۱۷٪ تغییر < 0.5٪
|
||
], 200),
|
||
]);
|
||
|
||
$this->artisan('ifnex:import-fx-rates', ['--source' => 'exir'])
|
||
->assertSuccessful();
|
||
|
||
// نباید هیچ رکوردی در تاریخچه بسازد
|
||
$this->assertDatabaseCount('exchange_rate_history', 0);
|
||
|
||
// نرخ currencies هم نباید تغییر کند
|
||
$this->assertSame('60000.0000', Currency::where('code', 'USD')->value('rate_to_rial'));
|
||
}
|
||
|
||
#[Test]
|
||
public function it_handles_exir_http_failure_gracefully()
|
||
{
|
||
$this->seedCurrencies();
|
||
|
||
Http::fake([
|
||
'https://api.exir.io/*' => Http::response([], 500),
|
||
]);
|
||
|
||
$result = $this->artisan('ifnex:import-fx-rates', ['--source' => 'exir']);
|
||
|
||
// نباید کد موفق بدهد چون هیچ نرخی دریافت نشد
|
||
$result->assertFailed();
|
||
|
||
// هیچ رکوردی نباید ساخته شود
|
||
$this->assertDatabaseCount('exchange_rate_history', 0);
|
||
}
|
||
|
||
#[Test]
|
||
public function it_skips_freecurrencyapi_when_api_key_is_missing()
|
||
{
|
||
$this->seedCurrencies();
|
||
|
||
config([
|
||
'ifnex.currency.api_key' => '', // بدون کلید
|
||
]);
|
||
|
||
Http::fake([
|
||
'https://api.freecurrencyapi.com/*' => Http::response([
|
||
'data' => ['IRR' => 60000, 'EUR' => 0.95, 'CNY' => 7.2],
|
||
], 200),
|
||
]);
|
||
|
||
$this->artisan('ifnex:import-fx-rates', ['--source' => 'freecurrencyapi'])
|
||
->assertFailed(); // چون هیچ نرخی دریافت نشد
|
||
|
||
// نباید رکوردی ساخته شود
|
||
$this->assertDatabaseCount('exchange_rate_history', 0);
|
||
}
|
||
|
||
#[Test]
|
||
public function it_fetches_eur_and_cny_from_freecurrencyapi()
|
||
{
|
||
$this->seedCurrencies();
|
||
|
||
config([
|
||
'ifnex.currency.api_key' => 'test-key-123',
|
||
]);
|
||
|
||
Http::fake([
|
||
'https://api.freecurrencyapi.com/*' => Http::response([
|
||
'data' => [
|
||
'IRR' => 60000.0, // 1 USD = 60,000 IRR
|
||
'EUR' => 0.95, // 1 USD = 0.95 EUR → 1 EUR = 0.95 × 60000 = 57000 IRR
|
||
'CNY' => 7.2, // 1 USD = 7.2 CNY → 1 CNY = 7.2 × 60000 = 432000 IRR
|
||
],
|
||
], 200),
|
||
]);
|
||
|
||
$this->artisan('ifnex:import-fx-rates', [
|
||
'--source' => 'freecurrencyapi',
|
||
'--auto-approve' => true,
|
||
])->assertSuccessful();
|
||
|
||
// باید در currencies نوشته شود
|
||
$this->assertDatabaseHas('currencies', ['code' => 'EUR']);
|
||
$this->assertDatabaseHas('currencies', ['code' => 'CNY']);
|
||
|
||
$this->assertDatabaseHas('exchange_rate_history', [
|
||
'currency_from' => 'EUR',
|
||
'status' => 'approved',
|
||
'api_source' => 'freecurrencyapi.com',
|
||
]);
|
||
}
|
||
|
||
#[Test]
|
||
public function it_warns_when_currencies_table_is_empty()
|
||
{
|
||
// جدول خالی است — نباید crash کند، باید هشدار دهد
|
||
Http::fake([
|
||
'https://api.exir.io/*' => Http::response([
|
||
'usdt-rls' => ['price' => 60000.0],
|
||
], 200),
|
||
]);
|
||
|
||
$this->artisan('ifnex:import-fx-rates', ['--source' => 'exir'])
|
||
->assertSuccessful();
|
||
|
||
// چون ارز USD وجود ندارد، نرخ نوشته نمیشود
|
||
$this->assertDatabaseCount('exchange_rate_history', 0);
|
||
$this->assertDatabaseCount('currencies', 0);
|
||
}
|
||
|
||
#[Test]
|
||
public function it_rejects_invalid_source_option()
|
||
{
|
||
$this->artisan('ifnex:import-fx-rates', ['--source' => 'invalid'])
|
||
->assertFailed();
|
||
}
|
||
}
|