This commit marks the completion of phase 3.11 (UAT Ready) and includes significant updates to deployment automation, testing, and documentation. Key changes: - Implement `ifnex:smoke-test` command for E2E post-deployment validation - Implement `ifnex:preflight` command for environment audit before migration - Implement `ifnex:import-fx-rates` for automated multi-source currency rate imports (Exir.io and FreeCurrencyAPI) - Update `CurrencySeeder` to support base currencies (USD, AED, EUR, CNY, IRR) - Enhance `IfnexSmokeTest` with improved environment-aware checks and robust HTTP mocking in tests - Update project documentation (Roadmap, README, AGENT, Handoff) to reflect completed phases 3.7 through 3.11 - Add UAT checklist and installation guides for recent hotfixes - Update project statistics (models, migrations, endpoints, and test counts)
319 lines
11 KiB
PHP
319 lines
11 KiB
PHP
<?php
|
||
|
||
namespace Tests\Feature\Console;
|
||
|
||
use App\Models\Currency;
|
||
use App\Models\User;
|
||
use Database\Seeders\CountriesTableSeeder;
|
||
use Database\Seeders\CurrencySeeder;
|
||
use Database\Seeders\RoleAndPermissionSeeder;
|
||
use Database\Seeders\SystemSettingSeeder;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Support\Facades\Http;
|
||
use PHPUnit\Framework\Attributes\Test;
|
||
use Spatie\Permission\Models\Role;
|
||
use Tests\TestCase;
|
||
|
||
/**
|
||
* تستهای دستور ifnex:smoke-test (فاز ۳.۱۱)
|
||
*
|
||
* این تستها با Http::fake کار میکنند تا بدون اتصال واقعی به HTTP، رفتار
|
||
* smoke-test را بررسی کنند. حالتهای اصلی:
|
||
* - تمام سبز وقتی همهچیز سالم است
|
||
* - شکست وقتی جدول currencies خالی است
|
||
* - شکست وقتی نقشها موجود نیستند
|
||
* - شکست وقتی هیچ ادمینی وجود ندارد
|
||
*/
|
||
class IfnexSmokeTestTest extends TestCase
|
||
{
|
||
use RefreshDatabase;
|
||
|
||
/**
|
||
* راهاندازی دادههای سالم برای تستهای PASS.
|
||
* شامل: ارزها، کشورها، نقشها، تنظیمات سیستم، یک کاربر ادمین.
|
||
*/
|
||
private function seedHealthyData(): void
|
||
{
|
||
$this->seed(CurrencySeeder::class);
|
||
$this->seed(CountriesTableSeeder::class);
|
||
$this->seed(RoleAndPermissionSeeder::class);
|
||
$this->seed(SystemSettingSeeder::class);
|
||
|
||
// نرخهای غیر صفر برای ارزها (شبیهسازی import-fx-rates اجرا شده)
|
||
Currency::where('code', 'USD')->update(['rate_to_rial' => 60000]);
|
||
Currency::where('code', 'AED')->update(['rate_to_rial' => 16200]);
|
||
Currency::where('code', 'EUR')->update(['rate_to_rial' => 65000]);
|
||
Currency::where('code', 'CNY')->update(['rate_to_rial' => 8000]);
|
||
|
||
// یک کاربر ادمین
|
||
$admin = User::factory()->create(['email' => 'admin@ifnex.ir']);
|
||
$admin->assignRole('super_admin');
|
||
}
|
||
|
||
/**
|
||
* HTTP fake برای endpointهای API که smoke-test صدا میزند.
|
||
*
|
||
* نکته مهم: بهجای تطبیق URL دقیق (که به APP_URL وابسته است)، از closure
|
||
* استفاده میکنیم که بر اساس مسیر (path) تطبیق میدهد. اینطوری حتی اگر
|
||
* config('app.url') چیزی غیر از http://localhost برگرداند، fake همچنان کار میکند.
|
||
*/
|
||
private function fakeApiEndpoints(string $baseUrl = 'http://localhost'): void
|
||
{
|
||
Http::fake(function ($request) {
|
||
$url = $request->url();
|
||
|
||
// تطبیق بر اساس مسیر نه host — بهاین ترتیب هم localhost و هم
|
||
// system.ifnex.ir و هر URL دیگری پشتیبانی میشود.
|
||
if (str_ends_with($url, '/up')) {
|
||
return Http::response('OK', 200);
|
||
}
|
||
|
||
if (str_contains($url, '/api/v1/countries')) {
|
||
return Http::response(['data' => []], 200);
|
||
}
|
||
|
||
if (str_contains($url, '/api/v1/track/')) {
|
||
return Http::response([], 404);
|
||
}
|
||
|
||
if (str_ends_with($url, '/api/v1/customer/profile')) {
|
||
return Http::response(['message' => 'Unauthenticated'], 401);
|
||
}
|
||
|
||
// fallback برای هر URL دیگر که smoke-test ممکن است صدا بزند
|
||
return Http::response([], 200);
|
||
});
|
||
}
|
||
|
||
// ═══════════════════ PASS scenarios ═══════════════════
|
||
|
||
#[Test]
|
||
public function it_passes_when_everything_is_healthy()
|
||
{
|
||
$this->seedHealthyData();
|
||
$this->fakeApiEndpoints();
|
||
|
||
$this->artisan('ifnex:smoke-test')
|
||
->assertSuccessful();
|
||
}
|
||
|
||
// ═══════════════════ FAIL scenarios ═══════════════════
|
||
|
||
#[Test]
|
||
public function it_fails_when_currencies_table_is_empty()
|
||
{
|
||
// ارزها ساخته نمیشوند — جدول خالی
|
||
$this->seed(CountriesTableSeeder::class);
|
||
$this->seed(RoleAndPermissionSeeder::class);
|
||
$this->seed(SystemSettingSeeder::class);
|
||
|
||
$admin = User::factory()->create();
|
||
$admin->assignRole('super_admin');
|
||
|
||
$this->fakeApiEndpoints();
|
||
|
||
$this->artisan('ifnex:smoke-test')
|
||
->assertFailed();
|
||
}
|
||
|
||
#[Test]
|
||
public function it_fails_when_currency_rate_is_zero()
|
||
{
|
||
$this->seedHealthyData();
|
||
|
||
// شبیهسازی نرخ صفر — import-fx-rates هنوز اجرا نشده
|
||
Currency::where('code', 'USD')->update(['rate_to_rial' => 0]);
|
||
|
||
$this->fakeApiEndpoints();
|
||
|
||
$this->artisan('ifnex:smoke-test')
|
||
->assertFailed();
|
||
}
|
||
|
||
#[Test]
|
||
public function it_fails_when_no_admin_user_exists()
|
||
{
|
||
$this->seedHealthyData();
|
||
|
||
// حذف همهٔ کاربران ادمین
|
||
User::query()->delete();
|
||
|
||
$this->fakeApiEndpoints();
|
||
|
||
$this->artisan('ifnex:smoke-test')
|
||
->assertFailed();
|
||
}
|
||
|
||
#[Test]
|
||
public function it_fails_when_countries_not_seeded()
|
||
{
|
||
$this->seed(CurrencySeeder::class);
|
||
$this->seed(RoleAndPermissionSeeder::class);
|
||
$this->seed(SystemSettingSeeder::class);
|
||
|
||
// کشورها ساخته نمیشوند
|
||
|
||
$admin = User::factory()->create();
|
||
$admin->assignRole('super_admin');
|
||
|
||
$this->fakeApiEndpoints();
|
||
|
||
$this->artisan('ifnex:smoke-test')
|
||
->assertFailed();
|
||
}
|
||
|
||
#[Test]
|
||
public function it_fails_when_roles_not_seeded()
|
||
{
|
||
// بدون RoleAndPermissionSeeder — نقشها موجود نیستند
|
||
$this->seed(CurrencySeeder::class);
|
||
$this->seed(CountriesTableSeeder::class);
|
||
$this->seed(SystemSettingSeeder::class);
|
||
|
||
// کاربر بدون assignRole (چون نقش وجود ندارد)
|
||
User::factory()->create();
|
||
|
||
$this->fakeApiEndpoints();
|
||
|
||
$this->artisan('ifnex:smoke-test')
|
||
->assertFailed();
|
||
}
|
||
|
||
#[Test]
|
||
public function it_fails_when_health_endpoint_returns_500()
|
||
{
|
||
$this->seedHealthyData();
|
||
|
||
// شبیهسازی شکست /up — از closure برای تطبیق مسیر استفاده میکنیم
|
||
Http::fake(function ($request) {
|
||
$url = $request->url();
|
||
if (str_ends_with($url, '/up')) {
|
||
return Http::response('Server Error', 500);
|
||
}
|
||
if (str_contains($url, '/api/v1/countries')) {
|
||
return Http::response(['data' => []], 200);
|
||
}
|
||
if (str_contains($url, '/api/v1/track/')) {
|
||
return Http::response([], 404);
|
||
}
|
||
if (str_ends_with($url, '/api/v1/customer/profile')) {
|
||
return Http::response(['message' => 'Unauthenticated'], 401);
|
||
}
|
||
return Http::response([], 200);
|
||
});
|
||
|
||
$this->artisan('ifnex:smoke-test')
|
||
->assertFailed();
|
||
}
|
||
|
||
#[Test]
|
||
public function it_fails_when_protected_endpoint_allows_unauthenticated_access()
|
||
{
|
||
$this->seedHealthyData();
|
||
|
||
// شبیهسازی باگ امنیتی: endpoint محافظتشده، 200 برمیگرداند (بهجای 401)
|
||
Http::fake(function ($request) {
|
||
$url = $request->url();
|
||
if (str_ends_with($url, '/up')) {
|
||
return Http::response('OK', 200);
|
||
}
|
||
if (str_contains($url, '/api/v1/countries')) {
|
||
return Http::response(['data' => []], 200);
|
||
}
|
||
if (str_contains($url, '/api/v1/track/')) {
|
||
return Http::response([], 404);
|
||
}
|
||
if (str_ends_with($url, '/api/v1/customer/profile')) {
|
||
return Http::response(['data' => ['id' => 1]], 200); // ← باید 401 باشد!
|
||
}
|
||
return Http::response([], 200);
|
||
});
|
||
|
||
$this->artisan('ifnex:smoke-test')
|
||
->assertFailed();
|
||
}
|
||
|
||
#[Test]
|
||
public function it_accepts_custom_base_url()
|
||
{
|
||
$this->seedHealthyData();
|
||
|
||
$customUrl = 'https://system.ifnex.ir';
|
||
|
||
// از closure استفاده میکنیم تا URL پایه مهم نباشد
|
||
Http::fake(function ($request) {
|
||
$url = $request->url();
|
||
if (str_ends_with($url, '/up')) {
|
||
return Http::response('OK', 200);
|
||
}
|
||
if (str_contains($url, '/api/v1/countries')) {
|
||
return Http::response(['data' => []], 200);
|
||
}
|
||
if (str_contains($url, '/api/v1/track/')) {
|
||
return Http::response([], 404);
|
||
}
|
||
if (str_ends_with($url, '/api/v1/customer/profile')) {
|
||
return Http::response(['message' => 'Unauthenticated'], 401);
|
||
}
|
||
return Http::response([], 200);
|
||
});
|
||
|
||
$this->artisan('ifnex:smoke-test', ['--url' => $customUrl])
|
||
->assertSuccessful();
|
||
}
|
||
|
||
#[Test]
|
||
public function it_accepts_422_for_tracking_endpoint()
|
||
{
|
||
$this->seedHealthyData();
|
||
|
||
// ممکن است controller برای AWB نامعتبر، 422 برگرداند بهجای 404 — این هم قابلقبول است
|
||
Http::fake(function ($request) {
|
||
$url = $request->url();
|
||
if (str_ends_with($url, '/up')) {
|
||
return Http::response('OK', 200);
|
||
}
|
||
if (str_contains($url, '/api/v1/countries')) {
|
||
return Http::response(['data' => []], 200);
|
||
}
|
||
if (str_contains($url, '/api/v1/track/')) {
|
||
return Http::response(['errors' => []], 422); // ← 422 بهجای 404
|
||
}
|
||
if (str_ends_with($url, '/api/v1/customer/profile')) {
|
||
return Http::response(['message' => 'Unauthenticated'], 401);
|
||
}
|
||
return Http::response([], 200);
|
||
});
|
||
|
||
$this->artisan('ifnex:smoke-test')
|
||
->assertSuccessful();
|
||
}
|
||
|
||
#[Test]
|
||
public function it_fails_when_tracking_endpoint_returns_500()
|
||
{
|
||
$this->seedHealthyData();
|
||
|
||
// 500 یعنی باگ در controller — نباید رخ دهد
|
||
Http::fake(function ($request) {
|
||
$url = $request->url();
|
||
if (str_ends_with($url, '/up')) {
|
||
return Http::response('OK', 200);
|
||
}
|
||
if (str_contains($url, '/api/v1/countries')) {
|
||
return Http::response(['data' => []], 200);
|
||
}
|
||
if (str_contains($url, '/api/v1/track/')) {
|
||
return Http::response('Fatal', 500);
|
||
}
|
||
if (str_ends_with($url, '/api/v1/customer/profile')) {
|
||
return Http::response(['message' => 'Unauthenticated'], 401);
|
||
}
|
||
return Http::response([], 200);
|
||
});
|
||
|
||
$this->artisan('ifnex:smoke-test')
|
||
->assertFailed();
|
||
}
|
||
}
|