ifnex/04_Laravel/tests/Feature/Console/IfnexSmokeTestTest.php
Kazem Alghasi defdcc22c9 feat(core): complete phase 3.11 and prepare for production deployment
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)
2026-11-08 06:28:21 +03:30

319 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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();
}
}