ifnex/04_Laravel/app/Console/Commands/IfnexSmokeTest.php

351 lines
13 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
{
try {
$count = DB::table('system_settings')->count();
} catch (Throwable $e) {
$this->addResult('system_settings query', 'ERR: ' . $e->getMessage(), false);
return;
}
// بررسی تعداد رکوردها — اگر جدول خالی باشد، seeder اجرا نشده
$this->addResult(
'system_settings seeded',
$count > 0 ? "{$count} settings" : 'EMPTY — php artisan db:seed --class=SystemSettingSeeder --force',
$count > 0
);
}
// ─── 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
{
// بررسی cache فقط در محیط Production — در محیط local/testing/cache-cache نباید
// اجرا شود (و این رفتار درست است). بنابراین در غیر Production این بررسی‌ها
// به‌عنوان N/A (not applicable) ثبت می‌شوند و fail محسوب نمی‌شوند.
$env = (string) config('app.env');
if ($env !== 'production') {
$this->addResult(
'config:cache اجرا شده',
"N/A (env={$env}) — فقط در Production",
true
);
$this->addResult(
'route:cache اجرا شده',
"N/A (env={$env}) — فقط در Production",
true
);
return;
}
// بررسی اینکه 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 (AWB نامعتبر) یا 401 (اگر endpoint با API Key محافظت می‌شود)
$this->checkUrl(
"{$baseUrl}/api/v1/track/INVALID-AWB-TEST",
[401, 404, 422],
'GET /api/v1/track/{invalid} — 401/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()
);
}
}