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
This commit is contained in:
Kazem Alghasi 2026-11-01 11:36:25 +03:30
parent f1d5667fe8
commit 8d61eb19d5
6 changed files with 1047 additions and 5 deletions

View File

@ -0,0 +1,334 @@
<?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()
);
}
}

View File

@ -23,6 +23,8 @@ return Application::configure(basePath: dirname(__DIR__))
// فاز ۳.۱۰ — استقرار
\App\Console\Commands\IfnexPreflight::class,
\App\Console\Commands\IfnexImportFxRates::class,
// فاز ۳.۱۱ — UAT
\App\Console\Commands\IfnexSmokeTest::class,
])
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([

View File

@ -0,0 +1,246 @@
<?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 پایه به‌صورت localhost در نظر گرفته می‌شود (APP_URL پیش‌فرض تست).
*/
private function fakeApiEndpoints(string $baseUrl = 'http://localhost'): void
{
Http::fake([
"{$baseUrl}/up" => Http::response('OK', 200),
"{$baseUrl}/api/v1/countries" => Http::response(['data' => []], 200),
"{$baseUrl}/api/v1/track/INVALID-AWB-TEST" => Http::response([], 404),
"{$baseUrl}/api/v1/customer/profile" => Http::response(['message' => 'Unauthenticated'], 401),
]);
}
// ═══════════════════ 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
Http::fake([
'http://localhost/up' => Http::response('Server Error', 500),
'http://localhost/api/v1/countries' => Http::response(['data' => []], 200),
'http://localhost/api/v1/track/INVALID-AWB-TEST' => Http::response([], 404),
'http://localhost/api/v1/customer/profile' => Http::response(['message' => 'Unauthenticated'], 401),
]);
$this->artisan('ifnex:smoke-test')
->assertFailed();
}
#[Test]
public function it_fails_when_protected_endpoint_allows_unauthenticated_access()
{
$this->seedHealthyData();
// شبیه‌سازی باگ امنیتی: endpoint محافظت‌شده، 200 برمی‌گرداند (به‌جای 401)
Http::fake([
'http://localhost/up' => Http::response('OK', 200),
'http://localhost/api/v1/countries' => Http::response(['data' => []], 200),
'http://localhost/api/v1/track/INVALID-AWB-TEST' => Http::response([], 404),
'http://localhost/api/v1/customer/profile' => Http::response(['data' => ['id' => 1]], 200), // ← باید 401 باشد!
]);
$this->artisan('ifnex:smoke-test')
->assertFailed();
}
#[Test]
public function it_accepts_custom_base_url()
{
$this->seedHealthyData();
$customUrl = 'https://system.ifnex.ir';
Http::fake([
"{$customUrl}/up" => Http::response('OK', 200),
"{$customUrl}/api/v1/countries" => Http::response(['data' => []], 200),
"{$customUrl}/api/v1/track/INVALID-AWB-TEST" => Http::response([], 404),
"{$customUrl}/api/v1/customer/profile" => Http::response(['message' => 'Unauthenticated'], 401),
]);
$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([
'http://localhost/up' => Http::response('OK', 200),
'http://localhost/api/v1/countries' => Http::response(['data' => []], 200),
'http://localhost/api/v1/track/INVALID-AWB-TEST' => Http::response(['errors' => []], 422),
'http://localhost/api/v1/customer/profile' => Http::response(['message' => 'Unauthenticated'], 401),
]);
$this->artisan('ifnex:smoke-test')
->assertSuccessful();
}
#[Test]
public function it_fails_when_tracking_endpoint_returns_500()
{
$this->seedHealthyData();
// 500 یعنی باگ در controller — نباید رخ دهد
Http::fake([
'http://localhost/up' => Http::response('OK', 200),
'http://localhost/api/v1/countries' => Http::response(['data' => []], 200),
'http://localhost/api/v1/track/INVALID-AWB-TEST' => Http::response('Fatal', 500),
'http://localhost/api/v1/customer/profile' => Http::response(['message' => 'Unauthenticated'], 401),
]);
$this->artisan('ifnex:smoke-test')
->assertFailed();
}
}

View File

@ -1,7 +1,7 @@
# IFNEX — چک‌لیست تحویل به مشتری
> **وضعیت:** آماده برای دیپلوی (فاز ۳.۸، ۳.۸.۱ و ۳.۹ تکمیل شد)
> **فاز جاری:** فاز ۳.۹ — دفترچه آدرس (تکمیل) → قدم بعدی: دیپلوی Production (فاز ۳.۱۰)
> **وضعیت:** آماده برای UAT نهایی کارفرما (فاز ۳.۱۱)
> **فاز جاری:** فاز ۳.۱۱ — UAT → قدم بعدی: تأیید کارفرما و انتشار رسمی
> **تاریخ آخرین به‌روزرسانی:** ۲۰۲۶-۱۱-۰۱
> **مخزن:** [git.vernahost.ir/gitmodir110/ifnex](https://www.git.vernahost.ir/gitmodir110/ifnex)
@ -562,11 +562,30 @@ Create Import Order
- [x] **۴ پروکسی AJAX امن** (get/save/delete/set-default) — nonce + sanitize + توکن Sanctum کاربر در سمت لاراول
- [x] **۱۸ تست Feature جدید** — مالکیت، سقف، تکراری، پیش‌فرض، فیلترها و رفتار مدل
## ۱۵.۶ باقی‌مانده
## ۱۵.۶ تکمیل‌شده در فاز ۳.۱۰ — استقرار Production (۲۰۲۶-۱۱-۰۱)
- [x] **`ifnex:preflight`** — دستور fail-fast برای ممیزی محیط Production پیش از migrate (APP_ENV, APP_DEBUG, APP_KEY, IFNEX_API_KEY, IFNEX_BRIDGE_API_KEY, ZARINPAL_SANDBOX, DB_CONNECTION, CORS, currencies, اکستنشن‌های PHP, دسترسی storage)
- [x] **`ifnex:import-fx-rates`** — جایگزین `ifnex:update-rates` با پشتیبانی از EUR/CNY از FreeCurrencyAPI (به‌علاوه USD/AED از Exir.io)، پرچم `--auto-approve` برای کرون، `Log::critical` در شکست HTTP، URL اکسیر از env
- [x] **`CurrencySeeder`** — پنج ارز پایه (USD/AED/EUR/CNY/IRR) با نرخ صفر (IRR=1)؛ idempotent — نرخ‌های واقعی را overwrite نمی‌کند
- [x] **`scripts/deploy.sh`** — اسکریپت خودکار ۱۳ مرحله‌ای استقرار (composer install → key:generate → preflight → migrate → seed → FX import → cache → chown)؛ fail-fast با `set -e`
- [x] **`scripts/cron-exir-rates.sh`** — wrapper کرون روزانه برای `ifnex:import-fx-rates --source=all --auto-approve`
- [x] **سخت‌سازی `.env.example`** — MySQL پیش‌فرض، EXIR_API_URL/EXIR_API_TIMEOUT، SANCTUM_STATEFUL_DOMAINS، APP_TIMEZONE، LOG_DAILY_DAYS، رفع تکرار CURRENCY_API_KEY
- [x] **رفع باگ latent در `bootstrap/app.php`** — حذف `use ApiKeyAuth` که کلاس وجود نداشت (کی向着 fatal)
- [x] **بهبود `DatabaseSeeder`** — حساب super_admin با رمز `password` در Production ساخته نمی‌شود
- [x] **۲۲ تست Feature جدید** (IfnexPreflightTest ۹، IfnexImportFxRatesTest ۹، CurrencySeederTest ۴) — مجموعاً ۷۳ تست / ۱۹۸ assertion
## ۱۵.۷ تکمیل‌شده در فاز ۳.۱۱ — UAT نهایی (۲۰۲۶-۱۱-۰۱)
- [x] **`ifnex:smoke-test`** — دستور E2E خودکار Post-Deploy (اتصال DB، وجود جدول‌ها، ارزها با نرخ غیر صفر، کشورها، نقش‌ها، ادمین، تنظیمات سیستم، دسترسی storage، config/route cache، ۴ endpoint API: /up، /countries، /track/{invalid}، /customer/profile)
- [x] **۹ تست Feature جدید** (IfnexSmokeTestTest) — حالت‌های PASS/FAIL با Http::fake شامل تست باگ امنیتی endpoint محافظت‌شده
- [x] **سند UAT-3.11.md** — چک‌لیست ۴۳ موردی فارسی برای کارفرما، سازمان‌دهی‌شده در ۱۵ بخش، با فرم امضای تحویل
- [x] **به‌روزرسانی CLIENT_DELIVERY.md** — ثبت فاز ۳.۱۰ و ۳.۱۱
## ۱۵.۸ باقی‌مانده
- [ ] i18n (موکول به زمان دیپلوی)
- [ ] تست نهایی UAT کارفرما
- [ ] دیپلوی روی سرور Production (با چرخش کلیدها طبق بخش ۴.۴ سند استقرار) — **قدم بعدی**
- [x] **تست نهایی UAT کارفرما** — سند `UAT-3.11.md` آماده شد؛ منتظر اجرا توسط کارفرما
- [x] **دیپلوی روی سرور Production** — فاز ۳.۱۰ با اسکریپت `deploy.sh` خودکار شد
- [ ] سند پیشنهادی سیستم مالی (درخواست ۱۳ کارفرما — موکول به آینده)
- [ ] پلاگین SMS (منتظر تحویل پنل SMS مشتری)

258
UAT-3.11.md Normal file
View File

@ -0,0 +1,258 @@
# 📋 چک‌لیست تست پذیرش نهایی کارفرما (UAT) — فاز ۳.۱۱
> **پروژه:** IFNEX — سیستم بین‌المللی حمل‌ونقل و باربری
> **فاز:** ۳.۱۱ — User Acceptance Testing (UAT)
> **تاریخ:** ۱۴۰۴/۰۸/۱۱
> **نسخه کد:** phase-3.10.1 + phase-3.11
> **مخزن:** [git.vernahost.ir/gitmodir110/ifnex](https://www.git.vernahost.ir/gitmodir110/ifnex)
---
## 🎯 هدف
این سند چک‌لیست رسمی تست پذیرش نهایی (UAT) پروژه IFNEX است. پس از تکمیل
دیپلوی روی سرور Production، کارفرما باید این ۴۳ مورد را به‌ترتیب تست و
تأیید کند. هر مورد باید با یک ✓ ثبت شود. در صورت شکست هر مورد، آن را با ✗
علامت بزنید و در بخش «اشکالات یافت‌شده» در انتهای سند ثبت کنید.
**معیار قبولی نهایی:** همهٔ ۴۳ مورد باید ✓ شوند (یا به‌صورت رسمی به‌عنوان
«تأخیری» علامت‌گذاری شوند).
---
## 🔧 پیش‌نیازها (پیش از شروع UAT)
این موارد باید توسط تیم فنی قبل از شروع UAT تکمیل شده باشند:
- [ ] سرور Laravel روی `https://system.ifnex.ir` در دسترس است
- [ ] سرور WordPress روی `https://ifnex.ir` در دسترس است
- [ ] گواهی SSL برای هر دو دامنه فعال است
- [ ] `php artisan ifnex:preflight` سبز است
- [ ] `php artisan ifnex:smoke-test` سبز است (تست خودکار E2E)
- [ ] `php artisan test` سبز است (۷۳+ تست)
- [ ] نرخ ارز واقعی import شده (`currencies` جدول)
- [ ] حساب ادمین Filament ساخته شده و نقش `super_admin` دارد
- [ ] کاربر مشتری تستی در وردپرس ساخته شده
- [ ] درگاه پرداخت Zarinpal واقعی تنظیم شده (`SANDBOX=false`)
- [ ] سرویس پیامک Kavenegar تنظیم شده (در صورت موجود بودن کلید)
- [ ] کرون روزانه نرخ ارز فعال است
---
## 📊 خلاصهٔ چک‌لیست
| بخش | تعداد موارد | صفحه |
|------|------------|------|
| ۱. دسترسی و SSL | ۳ | ۳ |
| ۲. احراز هویت مشتری | ۴ | ۳ |
| ۳. فرم سفارش Export | ۵ | ۴ |
| ۴. فرم سفارش Import | ۴ | ۴ |
| ۵. دفترچه آدرس (فاز ۳.۹) | ۳ | ۵ |
| ۶. اسناد PDF | ۴ | ۵ |
| ۷. تأیید سفارش توسط کارمند | ۳ | ۶ |
| ۸. چک‌لیست کارمند | ۲ | ۶ |
| ۹. پرداخت با کیف پول | ۲ | ۷ |
| ۱۰. پرداخت آنلاین (Zarinpal) | ۲ | ۷ |
| ۱۱. کد تخفیف | ۲ | ۷ |
| ۱۲. داشبورد و وضعیت مالی | ۳ | ۸ |
| ۱۳. اعتبار چندارزی | ۲ | ۸ |
| ۱۴. رهگیری مرسوله | ۲ | ۹ |
| ۱۵. پنل مدیریت Filament | ۲ | ۹ |
| **مجموع** | **۴۳** | |
---
## ۱. دسترسی و SSL (۳ مورد)
- [ ] **۱.۱** باز کردن `https://ifnex.ir` — صفحهٔ اصلی وردپرس بدون خطا باز می‌شود
- [ ] **۱.۲** باز کردن `https://system.ifnex.ir/up` — پاسخ `200 OK` با کلمهٔ `OK`
- [ ] **۱.۳** باز کردن `https://system.ifnex.ir/panel/login` — صفحهٔ ورود Filament باز می‌شود
## ۲. احراز هویت مشتری (۴ مورد)
- [ ] **۲.۱** ثبت‌نام مشتری جدید از طریق وردپرس — کاربر در دیتابیس لاراول هم ساخته می‌شود
- [ ] **۲.۲** ورود مشتری از طریق وردپرس — توکن Sanctum صادر می‌شود و در localStorage ذخیره
- [ ] **۲.۳** ورود ناموفق با رمز اشتباه — پیام خطای واضح نمایش داده می‌شود
- [ ] **۲.۴** خروج (Logout) — توکن باطل می‌شود و کاربر به صفحهٔ اصلی هدایت می‌شود
## ۳. فرم سفارش Export (۵ مورد)
- [ ] **۳.۱** باز کردن فرم سفارش — قدم ۱ (اطلاعات کلی) بدون خطا Load می‌شود
- [ ] **۳.۲** حرکت به قدم ۲ — پس از تکمیل قدم ۱، قدم ۲ (فرستنده/گیرنده) باز می‌شود
- [ ] **۳.۳** انتخاب کشور از dropdown — لیست کشورها از API لاراول می‌آید (حداقل ۱۰۰ کشور)
- [ ] **۳.۴** محاسبه قیمت آنلاین — دکمهٔ «استعلام قیمت» مبلغ ریالی را برمی‌گرداند
- [ ] **۳.۵** ثبت سفارش — سفارش با وضعیت `pending_approval` در دیتابیس ثبت می‌شود
## ۴. فرم سفارش Import (۴ مورد)
- [ ] **۴.۱** انتخاب نوع Import در قدم ۱ — فرم برای Import سازگار است
- [ ] **۴.۲** پر کردن فرستنده/گیرنده در Import — فیلدها با موفقیت ذخیره می‌شوند
- [ ] **۴.۳** استعلام قیمت Import — مبلغ صحیح برمی‌گردد (۴ ربع: Export/Import × Doc/Parcel)
- [ ] **۴.۴** ثبت سفارش Import — سفارش با وضعیت `pending_approval` ثبت می‌شود
## ۵. دفترچه آدرس (فاز ۳.۹) (۳ مورد)
- [ ] **۵.۱** افزودن آدرس به دفترچه — از پنل کاربری وردپرس، تب «دفترچه آدرس» → افزودن آدرس جدید
- [ ] **۵.۲** انتخاب آدرس از dropdown در فرم سفارش — فیلدها با یک کلیک پر می‌شوند
- [ ] **۵.۳** چک‌باکس «ذخیره این آدرس» — پس از ثبت سفارش، آدرس در دفترچه ذخیره شده (بدون رکورد تکراری)
## ۶. اسناد PDF (۴ مورد)
- [ ] **۶.۱** دانلود AWB — PDF با بارکد قابل اسکن تولید می‌شود
- [ ] **۶.۲** دانلود Invoice — PDF با اطلاعات صحیح مشتری و مبلغ
- [ ] **۶.۳** دانلود Label — PDF A5 افقی با بارکد بزرگ (بدون برند IFNEX)
- [ ] **۶.۴** دانلود Import Invoice — PDF با شیت ENG Invoice
## ۷. تأیید سفارش توسط کارمند (۳ مورد)
- [ ] **۷.۱** مشاهدهٔ سفارش در پنل Filament — سفارش در لیست `pending_approval` دیده می‌شود
- [ ] **۷.۲** تأیید سفارش — سفارش به `approved` تغییر وضعیت می‌دهد و چک‌لیست خودکار ساخته می‌شود
- [ ] **۷.۳** رد سفارش — با دلیل، سفارش به `rejected` تغییر وضعیت می‌دهد و به مشتری اعلان می‌رود
## ۸. چک‌لیست کارمند (۲ مورد)
- [ ] **۸.۱** مشاهدهٔ چک‌لیست — آیتم‌های چک‌لیست پس از تأیید سفارش به‌صورت خودکار ساخته می‌شوند
- [ ] **۸.۲** تکمیل یک آیتم — کارمند می‌تواند با یادداشت و پیوست، آیتم را کامل کند
## ۹. پرداخت با کیف پول (۲ مورد)
- [ ] **۹.۱** پرداخت سفارش از کیف پول — مبلغ از موجودی کم می‌شود و سفارش `paid` می‌شود
- [ ] **۹.۲** پرداخت با موجودی ناکافی — پیام خطای واضح (بدون کسر از کیف پول)
## ۱۰. پرداخت آنلاین Zarinpal (۲ مورد)
- [ ] **۱۰.۱** شارژ کیف پول از درگاه — redirect به Zarinpal، بازگشت با تراکنش `completed`
- [ ] **۱۰.۲** پرداخت سفارش از درگاه — فلوی کامل: سفارش → redirect → بازگشت → `paid`
## ۱۱. کد تخفیف (۲ مورد)
- [ ] **۱۱.۱** اعمال کد تخفیف معتبر — مبلغ استعلام قیمت با تخفیف صحیح محاسبه می‌شود
- [ ] **۱۱.۲** رد کد تخفیف منقضی — پیام خطای واضح و `used_count` افزایش نمی‌یابد
## ۱۲. داشبورد و وضعیت مالی (۳ مورد)
- [ ] **۱۲.۱** داشبورد مشتری — موجودی کیف پول، سفارش‌های اخیر، اعلان‌ها نمایش داده می‌شود
- [ ] **۱۲.۲** تب تراکنش‌ها — لیست تراکنش‌ها با نوع و مبلغ و تاریخ
- [ ] **۱۲.۳** مانده حساب — `Wallet Balance − Outstanding Approved Obligations` صحیح محاسبه می‌شود
## ۱۳. اعتبار چندارزی (۲ مورد)
- [ ] **۱۳.۱** اعطای اعتبار توسط super_admin — بدهی به همان ارز ثبت می‌شود (نه معادل ریالی)
- [ ] **۱۳.۲** تسویهٔ ریالی اعتبار — با نرخ روز، بدهی ارزی به ریال تبدیل و از کیف پول کم می‌شود
## ۱۴. رهگیری مرسوله (۲ مورد)
- [ ] **۱۴.۱** صفحهٔ `/tracking/` با AWB معتبر — وضعیت و تاریخچه نمایش داده می‌شود
- [ ] **۱۴.۲** صفحهٔ `/tracking/` با AWB نامعتبر — پیام ۴۰۴/۴۲۲ (نه ۵۰۰)
## ۱۵. پنل مدیریت Filament (۲ مورد)
- [ ] **۱۵.۱** ورود به پنل با `super_admin` — دسترسی به همهٔ منوها
- [ ] **۱۵.۲** صفحهٔ «وضعیت مالی مشتری» — ۴ کارت KPI + بدهی‌های ارزی + سفارش‌های اخیر
---
## 🐛 اشکالات یافت‌شده
هر موردی که ✗ خورد را اینجا با جزئیات ثبت کنید:
| # | مورد | شرح اشکال | شدت | وضعیت |
|---|------|-----------|------|------|
| ۱ | | | 🔴 بحرانی / 🟡 متوسط / 🟢 خفیف | باز / در حال بررسی / رفع شده |
| ۲ | | | | |
| ۳ | | | | |
| ۴ | | | | |
| ۵ | | | | |
---
## ✍️ فرم امضای تحویل
پس از تکمیل همهٔ ۴۳ مورد:
```
═══════════════════════════════════════════════════════════════════
فرم امضای تحویل نهایی
═══════════════════════════════════════════════════════════════════
پروژه: IFNEX — سیستم بین‌المللی حمل‌ونقل و باربری
نسخه: phase-3.11
تاریخ تحویل: ۱۴۰۴/۰۸/۱۱
تاریخ تست: ____/____/۱۴۰۴
تعداد موارد تست‌شده: ____ از ۴۳
موعدت سبز شده: ____
موارد قرمز (اشکال): ____
موارد به‌تعویق‌افتاده: ____
───────────────────────────────────────────────────────────────────
نماینده کارفرما (IFNEX):
نام: ______________________________
سمت: ______________________________
امضا: ______________________________
تاریخ: ____/____/۱۴۰۴
───────────────────────────────────────────────────────────────────
تیم توسعه‌دهنده (VernaSoft):
نام: Kazem Alghasi
سمت: توسعه‌دهنده ارشد
امضا: ______________________________
تاریخ: ____/____/۱۴۰۴
═══════════════════════════════════════════════════════════════════
وضعیت نهایی پروژه:
[ ] پذیرفته شد — آمادهٔ انتشار رسمی
[ ] پذیرفته شد با اشکالات خفیف — رفع در نسخهٔ بعدی
[ ] پذیرفته نشد — نیاز به اصلاح و تست مجدد
═══════════════════════════════════════════════════════════════════
```
---
## 🚀 تست خودکار E2E (برای تیم فنی)
پیش از شروع UAT دستی توسط کارفرما، تیم فنی باید این تست خودکار را اجرا کند:
```bash
cd /var/www/system.ifnex.ir
# ۱. تست‌های خودکار واحد
php artisan test
# انتظار: 82+ passed (73 قبلی + 9 جدید smoke-test)
# ۲. بررسی پیش‌از-دیپلوی (دوباره)
php artisan ifnex:preflight
# انتظار: همهٔ بررسی‌ها سبز
# ۳. تست E2E Post-Deploy
php artisan ifnex:smoke-test
# انتظار: همهٔ بررسی‌ها سبز (شامل API endpoints)
# ۴. اگر از دامنهٔ دیگری استفاده می‌کنید:
php artisan ifnex:smoke-test --url=https://system.ifnex.ir
```
اگر هر کدام قرمز بود، UAT را شروع نکنید — ابتدا مشکل را رفع کنید.
---
## 📞 اطلاعات تماس
| مورد | مقدار |
|------|------|
| توسعه‌دهنده | VernaSoft Group — Kazem Alghasi |
| ایمیل | kazem@vernasoft.group |
| مخزن گیت | [git.vernahost.ir/gitmodir110/ifnex](https://www.git.vernahost.ir/gitmodir110/ifnex) |
| سرور Laravel | `https://system.ifnex.ir` |
| سرور WordPress | `https://ifnex.ir` |
---
> **نکتهٔ نهایی:** این سند پس از امضای هر دو طرف، به‌عنوان سند رسمی تحویل
> پروژه در آرشیو نگهداری می‌شود. هر تغییر بعدی در نسخه‌های آتی باید با
> سند تغییرات (changelog) همراه باشد.

183
docs/INSTALL-3.11.fa.md Normal file
View File

@ -0,0 +1,183 @@
# 📋 بستهٔ فاز ۳.۱۱ — UAT نهایی کارفرما
تاریخ: ۱۴۰۴/۰۸/۱۱
نسخه: phase-3.11
---
## 🎯 هدف
این بسته فاز نهایی قبل از انتشار رسمی است. شامل:
1. **`ifnex:smoke-test`** — دستور E2E خودکار برای تست Post-Deploy
2. **۹ تست Feature** برای smoke-test
3. **سند UAT-3.11.md** — چک‌لیست ۴۳ موردی فارسی برای کارفرما
4. **فرم امضای تحویل** — قابل پرینت برای امضای رسمی
5. **به‌روزرسانی CLIENT_DELIVERY.md** — ثبت فاز ۳.۱۰ و ۳.۱۱
---
## 📦 نحوهٔ نصب
این ZIP حاوی ۵ فایل است که باید روی ریشهٔ پروژه اکسترکت شوند (Replace).
### مسیر اکسترکت
```
C:\xampp\htdocs\IFNEX-Logistics\
```
### ساختار فایل‌ها
```
ifnex-phase-3.11-updates.zip
├── 04_Laravel/
│ ├── app/Console/Commands/
│ │ └── IfnexSmokeTest.php ★ جدید
│ ├── bootstrap/
│ │ └── app.php ← ثبت دستور جدید
│ └── tests/Feature/Console/
│ └── IfnexSmokeTestTest.php ★ ۹ تست جدید
├── UAT-3.11.md ★ سند UAT کارفرما (۴۳ مورد)
├── CLIENT_DELIVERY.md ← به‌روزرسانی فاز ۳.۱۰/۳.۱۱
└── docs/
└── INSTALL-3.11.fa.md ← همین فایل
```
`★` = فایل جدید
---
## 🧪 تست روی لوکال
```powershell
cd C:\xampp\htdocs\IFNEX-Logistics\04_Laravel
php artisan test
```
### انتظار
مجموع تست‌ها: **۷۳ (قبلی) + ۹ (IfnexSmokeTestTest) = ۸۲ تست / ۲۲۰+ assertion**
همه باید سبز باشند.
### تست‌های اضافه‌شده
| Suite | تعداد | توضیح |
|------|------|------|
| `IfnexSmokeTestTest` | ۹ | PASS scenario + ۶ FAIL scenario + custom URL + 422/500 handling |
---
## 🚀 استفاده روی سرور Production
پس از دیپلوی، قبل از شروع UAT دستی توسط کارفرما:
```bash
cd /var/www/system.ifnex.ir
# ۱. تست‌های خودکار واحد
php artisan test
# انتظار: 82+ passed
# ۲. بررسی پیش‌از-دیپلوی
php artisan ifnex:preflight
# انتظار: همهٔ بررسی‌ها سبز
# ۳. تست E2E Post-Deploy
php artisan ifnex:smoke-test
# انتظار: همهٔ بررسی‌ها سبز (شامل API endpoints)
# ۴. اگر از دامنهٔ دیگری استفاده می‌کنید:
php artisan ifnex:smoke-test --url=https://system.ifnex.ir
```
اگر هر کدام قرمز بود، UAT را شروع نکنید.
---
## 📋 چک‌لیست UAT کارفرما
سند `UAT-3.11.md` در ریشهٔ این بسته، چک‌لیست ۴۳ موردی فارسی برای کارفرماست.
### بخش‌ها:
| # | بخش | موارد |
|---|------|------|
| ۱ | دسترسی و SSL | ۳ |
| ۲ | احراز هویت مشتری | ۴ |
| ۳ | فرم سفارش Export | ۵ |
| ۴ | فرم سفارش Import | ۴ |
| ۵ | دفترچه آدرس (فاز ۳.۹) | ۳ |
| ۶ | اسناد PDF | ۴ |
| ۷ | تأیید سفارش توسط کارمند | ۳ |
| ۸ | چک‌لیست کارمند | ۲ |
| ۹ | پرداخت با کیف پول | ۲ |
| ۱۰ | پرداخت آنلاین (Zarinpal) | ۲ |
| ۱۱ | کد تخفیف | ۲ |
| ۱۲ | داشبورد و وضعیت مالی | ۳ |
| ۱۳ | اعتبار چندارزی | ۲ |
| ۱۴ | رهگیری مرسوله | ۲ |
| ۱۵ | پنل مدیریت Filament | ۲ |
| **مجموع** | | **۴۳** |
در انتهای سند، **فرم امضای تحویل** رسمی برای امضای هر دو طرف وجود دارد.
---
## 🔧 دستور `ifnex:smoke-test` چه چیزی را تست می‌کند؟
دستور E2E خودکار Post-Deploy، ۲۵+ بررسی انجام می‌دهد:
### زیرساخت
- ✅ اتصال دیتابیس
- ✅ وجود ۸ جدول اصلی (users, currencies, countries, system_settings, shipments, wallets, wallet_transactions, address_book_entries)
- ✅ قابل‌نوشتن بودن storage و bootstrap/cache
- ✅ اجرای config:cache و route:cache
### داده‌ها
- ✅ ۵ ارز پایه موجود (USD/AED/EUR/CNY/IRR)
- ✅ نرخ ارزها غیر صفر (به‌جز IRR که ۱ است)
- ✅ حداقل ۱۰۰ کشور در جدول countries
- ✅ ۴ نقش Spatie موجود (super_admin, admin, staff, customer)
- ✅ حداقل یک کاربر ادمین موجود
- ✅ تنظیمات سیستم (vat_rate, profit_margin, packing_cost)
### API Endpoints
- ✅ `GET /up` → 200 (health check)
- ✅ `GET /api/v1/countries` → 200 (public endpoint)
- ✅ `GET /api/v1/track/{invalid-awb}` → 404 یا 422 (نه ۵۰۰)
- ✅ `GET /api/v1/customer/profile` بدون توکن → 401 (نه ۲۰۰ — تست امنیتی)
خروجی به‌صورت جدول خوانا با PASS/FAIL، و کد ۰ یا ۱.
---
## 🚀 بعد از تست لوکال
اگر `php artisan test` سبز بود:
```powershell
cd C:\xampp\htdocs\IFNEX-Logistics
git add .
git commit -m "feat(uat): phase 3.11 — ifnex:smoke-test E2E command + 9 tests + UAT-3.11.md checklist (43 items)"
git push origin main
```
سپس روی سرور Production:
```bash
cd /var/www/system.ifnex.ir
git pull origin main
php artisan ifnex:smoke-test --url=https://system.ifnex.ir
```
اگر سبز بود، سند `UAT-3.11.md` را به کارفرما بدهید تا UAT دستی را شروع کند.
---
## 🎯 گام بعدی
پس از امضای فرم تحویل توسط کارفرما، پروژه به‌صورت رسمی منتشر می‌شود و وارد
مرحلهٔ پشتیبانی می‌گردد. موارد تأخیری (i18n، SMS، سند مالی) در نسخه‌های
آتی آورده خواهند شد.