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
335 lines
12 KiB
PHP
335 lines
12 KiB
PHP
<?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()
|
||
);
|
||
}
|
||
}
|