ifnex/04_Laravel/app/Console/Commands/IfnexSmokeTest.php
Kazem Alghasi 8d61eb19d5 feat(uat): implement smoke test command and UAT documentation
Add `ifnex:smoke-test` console command to perform automated end-to-end
checks post-deployment, including database connectivity, essential
data presence, and API endpoint availability.

- Implement `IfnexSmokeTest` command for automated system verification
- Add feature tests for the smoke test command
- Create `UAT-3.11.md` containing the client acceptance checklist
- Update `CLIENT_DELIVERY.md` to reflect phase 3.10 and 3.11 completion
- Register the new command in `bootstrap/app.php`
- Add project documentation directory
2026-11-01 11:36:25 +03:30

335 lines
12 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 App\Console\Commands;
use App\Models\Currency;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
use Throwable;
/**
* IfnexSmokeTest — فاز ۳.۱۱
*
* تست End-to-End خودکار برای Post-Deploy. این دستور بعد از deploy روی سرور
* Production اجرا می‌شود تا مطمئن شویم همهٔ اجزای حیاتی سیستم سالم هستند.
*
* تفاوت با ifnex:preflight:
* - preflight: قبل از migrate اجرا می‌شود، فقط .env و config را چک می‌کند
* - smoke-test: بعد از deploy اجرا می‌شود، داده‌های واقعی و endpointهای API را چک می‌کند
*
* نمونهٔ استفاده:
* php artisan ifnex:smoke-test
* php artisan ifnex:smoke-test --url=https://system.ifnex.ir
*
* خروجی:
* - جدول خوانا از همهٔ بررسی‌ها
* - کد 0 اگر همه سبز باشند
* - کد 1 اگر حتی یکی قرمز باشد
*/
class IfnexSmokeTest extends Command
{
protected $signature = 'ifnex:smoke-test
{--url= : URL پایه برای تست endpointها (پیش‌فرض: APP_URL)}';
protected $description = 'تست End-to-End خودکار Post-Deploy — بررسی داده‌ها، APIها و زیرساخت';
/** @var array<int,array{check:string,result:string,passed:bool}> */
private array $results = [];
public function handle(): int
{
$this->info('🚀 در حال اجرای تست E2E Post-Deploy IFNEX...');
$this->newLine();
$this->checkDatabaseConnection();
$this->checkCoreTablesExist();
$this->checkCurrenciesSeeded();
$this->checkCountriesSeeded();
$this->checkRolesExist();
$this->checkAdminUserExists();
$this->checkSystemSettings();
$this->checkStorageWritable();
$this->checkCacheConfig();
$this->checkApiEndpoints();
$this->renderReport();
$failed = collect($this->results)->filter(fn ($r) => ! $r['passed'])->count();
if ($failed > 0) {
$this->newLine();
$this->error("❌ {$failed} بررسی ناموفق بود — سیستم آمادهٔ UAT نیست.");
return Command::FAILURE;
}
$this->newLine();
$this->info('✅ همهٔ بررسی‌ها سبز هستند. سیستم آمادهٔ UAT توسط کارفرماست.');
return Command::SUCCESS;
}
// ─── اتصال دیتابیس ───────────────────────────────────────
private function checkDatabaseConnection(): void
{
try {
DB::select('SELECT 1');
$this->addResult('اتصال دیتابیس', 'OK', true);
} catch (Throwable $e) {
$this->addResult('اتصال دیتابیس', 'FAIL: ' . $e->getMessage(), false);
}
}
// ─── وجود جدول‌های اصلی ──────────────────────────────────
private function checkCoreTablesExist(): void
{
$required = [
'users',
'currencies',
'countries',
'system_settings',
'shipments',
'wallets',
'wallet_transactions',
'address_book_entries',
];
foreach ($required as $table) {
try {
$exists = Schema::hasTable($table);
$this->addResult("table: {$table}", $exists ? 'exists' : 'MISSING', $exists);
} catch (Throwable $e) {
$this->addResult("table: {$table}", 'ERR: ' . $e->getMessage(), false);
}
}
}
// ─── ارزها ──────────────────────────────────────────────
private function checkCurrenciesSeeded(): void
{
$required = ['USD', 'AED', 'EUR', 'CNY', 'IRR'];
try {
$currencies = Currency::whereIn('code', $required)->get()->keyBy('code');
} catch (Throwable $e) {
$this->addResult('currencies query', 'ERR: ' . $e->getMessage(), false);
return;
}
$missing = array_diff($required, $currencies->keys()->all());
$this->addResult(
'5 ارز پایه موجود (USD/AED/EUR/CNY/IRR)',
empty($missing) ? 'all 5 present' : 'MISSING: ' . implode(',', $missing),
empty($missing)
);
// بررسی نرخ غیر صفر (به‌جز IRR که ۱ است)
foreach (['USD', 'AED', 'EUR', 'CNY'] as $code) {
$currency = $currencies->get($code);
$rate = $currency ? (float) $currency->rate_to_rial : 0;
$this->addResult(
"rate: {$code} > 0",
$rate > 0 ? "rate = {$rate}" : 'rate = 0 (import نشده)',
$rate > 0
);
}
}
// ─── کشورها ─────────────────────────────────────────────
private function checkCountriesSeeded(): void
{
try {
$count = DB::table('countries')->count();
} catch (Throwable $e) {
$this->addResult('countries count', 'ERR: ' . $e->getMessage(), false);
return;
}
$this->addResult(
'countries seeded',
"{$count} countries",
$count >= 100 // انتظار حداقل ۱۰۰ کشور
);
}
// ─── نقش‌ها ──────────────────────────────────────────────
private function checkRolesExist(): void
{
$required = ['super_admin', 'admin', 'staff', 'customer'];
try {
$existing = DB::table('roles')->whereIn('name', $required)->pluck('name')->all();
} catch (Throwable $e) {
$this->addResult('roles query', 'ERR: ' . $e->getMessage(), false);
return;
}
$missing = array_diff($required, $existing);
$this->addResult(
'roles seeded',
empty($missing) ? 'all 4 present' : 'MISSING: ' . implode(',', $missing),
empty($missing)
);
}
// ─── حساب ادمین ─────────────────────────────────────────
private function checkAdminUserExists(): void
{
try {
$count = User::whereHas('roles', function ($q) {
$q->whereIn('name', ['super_admin', 'admin']);
})->count();
} catch (Throwable $e) {
$this->addResult('admin users query', 'ERR: ' . $e->getMessage(), false);
return;
}
$this->addResult(
'admin user exists',
$count > 0 ? "{$count} admin(s)" : 'NO ADMIN USER',
$count > 0
);
}
// ─── تنظیمات سیستم ──────────────────────────────────────
private function checkSystemSettings(): void
{
$required = ['vat_rate', 'profit_margin', 'packing_cost'];
try {
$existing = DB::table('system_settings')
->whereIn('key', $required)
->pluck('key')
->all();
} catch (Throwable $e) {
$this->addResult('system_settings query', 'ERR: ' . $e->getMessage(), false);
return;
}
$missing = array_diff($required, $existing);
$this->addResult(
'system_settings seeded',
empty($missing) ? 'all present' : 'MISSING: ' . implode(',', $missing),
empty($missing)
);
}
// ─── storage قابل‌نوشتن ──────────────────────────────────
private function checkStorageWritable(): void
{
$paths = [
'storage' => base_path('storage'),
'storage/logs' => base_path('storage/logs'),
'storage/app' => base_path('storage/app'),
'storage/framework' => base_path('storage/framework'),
'bootstrap/cache' => base_path('bootstrap/cache'),
];
foreach ($paths as $label => $path) {
$writable = file_exists($path) && is_writable($path);
$this->addResult(
"writable: {$label}",
$writable ? 'writable' : 'NOT writable',
$writable
);
}
}
// ─── Cache config ───────────────────────────────────────
private function checkCacheConfig(): void
{
// بررسی اینکه config cache ساخته شده یا نه
$configCached = file_exists(base_path('bootstrap/cache/config.php'));
$this->addResult(
'config:cache اجرا شده',
$configCached ? 'yes' : 'no — برای سرعت اجرا کنید: php artisan config:cache',
$configCached
);
$routeCached = file_exists(base_path('bootstrap/cache/routes-v7.php'));
$this->addResult(
'route:cache اجرا شده',
$routeCached ? 'yes' : 'no — برای سرعت اجرا کنید: php artisan route:cache',
$routeCached
);
}
// ─── API Endpoints ──────────────────────────────────────
private function checkApiEndpoints(): void
{
$baseUrl = (string) ($this->option('url') ?: config('app.url'));
// حذف slash انتهایی
$baseUrl = rtrim($baseUrl, '/');
// ۱. Health check endpoint
$this->checkUrl("{$baseUrl}/up", 200, 'GET /up (health)');
// ۲. Public countries endpoint (بدون auth)
$this->checkUrl("{$baseUrl}/api/v1/countries", 200, 'GET /api/v1/countries');
// ۳. Tracking endpoint با AWB نامعتبر — انتظار 404 یا 422 (نه 500)
$this->checkUrl(
"{$baseUrl}/api/v1/track/INVALID-AWB-TEST",
[404, 422],
'GET /api/v1/track/{invalid} — 404/422 expected (نه 500)'
);
// ۴. Protected endpoint بدون توکن — انتظار 401
$this->checkUrl(
"{$baseUrl}/api/v1/customer/profile",
401,
'GET /api/v1/customer/profile (بدون توکن) — 401 expected'
);
}
private function checkUrl(string $url, int|array $expectedStatus, string $label): void
{
try {
$response = Http::timeout(10)->get($url);
$status = $response->status();
$expected = is_array($expectedStatus) ? $expectedStatus : [$expectedStatus];
$ok = in_array($status, $expected, true);
$this->addResult(
$label,
"HTTP {$status}" . ($ok ? '' : ' (expected ' . implode('/', $expected) . ')'),
$ok
);
} catch (Throwable $e) {
$this->addResult(
$label,
'ERR: ' . $e->getMessage(),
false
);
}
}
// ─── Helpers ────────────────────────────────────────────
private function addResult(string $check, string $result, bool $passed): void
{
$this->results[] = compact('check', 'result', 'passed');
}
private function renderReport(): void
{
$this->table(
['Check', 'Value', 'Status'],
collect($this->results)->map(fn ($r) => [
$r['check'],
$r['result'],
$r['passed'] ? '<fg=green>✓ PASS</>' : '<fg=red>✗ FAIL</>',
])->all()
);
}
}