From b21b1367112fbda56a9a457730e936d324912afd Mon Sep 17 00:00:00 2001 From: mohammad sedighi Date: Tue, 25 Aug 2026 03:01:07 +0330 Subject: [PATCH] fix(settings): honor last-price switch and overlay only SuperAdmin access flags Stored DB prices were treated as live quotes, and SuperAdmin overlay overwrote tenant keys like trading hours. Keep overlay to the 14 access flags and hide fallback prices when the panel says so. Co-authored-by: Cursor --- .../SettingsRestoreFromBackupCommand.php | 40 +------ .../Controllers/Admin/SettingsController.php | 22 ++-- .../Controllers/Site/ReportController.php | 10 +- app/Services/ConfigService.php | 108 ++++++++++++++---- app/Services/LivePriceCacheService.php | 18 ++- .../SuperAdminProgramSettingsClient.php | 6 +- .../SuperAdminProgramSettingsOverlay.php | 49 +++++--- tests/Unit/ConfigServiceTradingHoursTest.php | 92 +++++++++++++++ .../SuperAdminProgramSettingsOverlayTest.php | 79 +++++++++++++ 9 files changed, 327 insertions(+), 97 deletions(-) create mode 100644 tests/Unit/SuperAdminProgramSettingsOverlayTest.php diff --git a/app/Console/Commands/SettingsRestoreFromBackupCommand.php b/app/Console/Commands/SettingsRestoreFromBackupCommand.php index 18cae5b..542e0ba 100644 --- a/app/Console/Commands/SettingsRestoreFromBackupCommand.php +++ b/app/Console/Commands/SettingsRestoreFromBackupCommand.php @@ -3,7 +3,6 @@ namespace App\Console\Commands; use App\Models\Setting; -use App\Services\SuperAdminProgramSettingsClient; use App\Services\SuperAdminProgramSettingsOverlay; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; @@ -16,7 +15,8 @@ use Illuminate\Support\Facades\Schema; * قواعد سخت: * 1) value غیرخالی لوکال هرگز عوض نمی‌شود (مگر --force-overwrite). * 2) پیش‌فرض‌های seed/deploy (مثل limangold.ir) هرگز به‌عنوان مقدار واقعی نوشته نمی‌شوند. - * 3) با --fill-from-superadmin برای خالی/غایب: اول SA واقعی، بعد بکاپ واقعی. + * 3) --fill-from-superadmin دیگر کلیدهای غیر دسترسی را از سوپرادمین نمی‌خواند + * (سوپرادمین فقط ۱۴ دسترسی را مالک است). * * کلیدهای گروه «دسترسی مشتری به قابلیت‌ها» و runtime رد می‌شوند. */ @@ -78,7 +78,10 @@ class SettingsRestoreFromBackupCommand extends Command } $backupRows = $this->readBackup($table); - $superAdminValues = $fillFromSuperAdmin ? $this->superAdminValues() : []; + $superAdminValues = []; + if ($fillFromSuperAdmin) { + $this->warn('--fill-from-superadmin نادیده گرفته شد: سوپرادمین فقط دسترسی‌ها را دارد، نه بقیهٔ تنظیمات.'); + } if ($backupRows === [] && $superAdminValues === []) { $this->warn('هیچ کلید قابل بازگردانی پیدا نشد.'); @@ -332,37 +335,6 @@ class SettingsRestoreFromBackupCommand extends Command return $rows; } - /** - * @return array - */ - private function superAdminValues(): array - { - $items = app(SuperAdminProgramSettingsClient::class)->fetchItems(); - if (! is_array($items)) { - $this->warn('سوپرادمین در دسترس نبود — فالبک نادیده گرفته شد.'); - - return []; - } - - $values = []; - foreach ($items as $item) { - if (! is_array($item)) { - continue; - } - $key = trim((string) ($item['key'] ?? '')); - if ($key === '' || ! $this->isRestorable($key)) { - continue; - } - $value = $item['value'] ?? null; - if ($value === null || trim((string) $value) === '') { - continue; - } - $values[$key] = trim((string) $value); - } - - return $values; - } - private function isRestorable(string $key): bool { if (SuperAdminProgramSettingsOverlay::isSuperAdminOwned($key)) { diff --git a/app/Http/Controllers/Admin/SettingsController.php b/app/Http/Controllers/Admin/SettingsController.php index aa851dd..07131ec 100644 --- a/app/Http/Controllers/Admin/SettingsController.php +++ b/app/Http/Controllers/Admin/SettingsController.php @@ -214,11 +214,12 @@ class SettingsController extends Controller private function isPanelManagedKey(string $key): bool { - return in_array($key, self::PANEL_MANAGED_KEYS, true); + return in_array($key, self::PANEL_MANAGED_KEYS, true) + || app(ConfigService::class)->isTradingHoursSettingKey($key); } /** - * تنظیمات پیشرفته: can_edit=0 (سوپرادمین) + کلیدهای can_edit=1 که کد مستقیم می‌نویسد. + * تنظیمات پیشرفته: ۱۴ دسترسی از سوپرادمین + بقیهٔ can_edit=0 از DB لوکال. */ private function composeSystemSettings($localRows) { @@ -236,10 +237,7 @@ class SettingsController extends Controller if (! is_array($item)) { continue; } - if ( - SuperAdminProgramSettingsOverlay::isRuntime((string) $key) - || SuperAdminProgramSettingsOverlay::isLocalOwned((string) $key) - ) { + if (! SuperAdminProgramSettingsOverlay::isSuperAdminOwned((string) $key)) { continue; } $value = $item['value'] ?? null; @@ -292,7 +290,8 @@ class SettingsController extends Controller $key = (string) ($row->key ?? ''); return $this->isEditableCanEdit($row->can_edit ?? null) - || SuperAdminProgramSettingsOverlay::isRuntime($key); + || SuperAdminProgramSettingsOverlay::isRuntime($key) + || SuperAdminProgramSettingsOverlay::isLocalOwned($key); }); return $rows @@ -303,8 +302,8 @@ class SettingsController extends Controller /** * get_settings ادمین (و پایهٔ publicIndex قبل از فیلتر show_to_user): - * can_edit=1 (+ runtime) از DB لوکال - * can_edit=0 از سوپرادمین؛ اگر کلید در SA نبود یا value خالی بود → کپی لوکال + * can_edit=1 (+ runtime + local-owned) از DB لوکال + * فقط ۱۴ دسترسی سوپرادمین از overlay؛ بقیهٔ can_edit=0 از کپی لوکال * اگر سوپرادمین در دسترس نباشد کل جدول لوکال برمی‌گردد. */ private function composeGetSettings($localRows) @@ -338,10 +337,7 @@ class SettingsController extends Controller continue; } $key = (string) $key; - if ( - SuperAdminProgramSettingsOverlay::isRuntime($key) - || SuperAdminProgramSettingsOverlay::isLocalOwned($key) - ) { + if (! SuperAdminProgramSettingsOverlay::isSuperAdminOwned($key)) { continue; } $value = $item['value'] ?? null; diff --git a/app/Http/Controllers/Site/ReportController.php b/app/Http/Controllers/Site/ReportController.php index ef821ad..cf65a15 100644 --- a/app/Http/Controllers/Site/ReportController.php +++ b/app/Http/Controllers/Site/ReportController.php @@ -99,9 +99,11 @@ class ReportController extends Controller { // status: open | closed (خارج از ساعت کاری) | no_quote (مظنه در دسترس نیست) $payload = app(ConfigService::class)->applyHomePriceDisplayState($payload); - $payload = app(ConfigService::class)->attachTradingHoursSettingsPatch($payload); - $payload['up'] = $this->buildUpDownPrices(); + // سقف/کف روز هم وقتی قیمت نباید نمایش داده شود صفر می‌ماند + $payload['up'] = ($payload['status'] ?? 'open') === 'open' + ? $this->buildUpDownPrices() + : ['sell' => 0, 'buy' => 0]; $payload['last_historical'] = app(ProductDisplayPriceService::class)->resolveHistoricalBasePrice(); $payload['gold_ounce'] = $this->resolveGoldOuncePayload(); app(ProductDisplayPriceService::class)->cacheLivePricePayload($payload); @@ -476,6 +478,10 @@ class ReportController extends Controller /** اختلاف پکیج ویژه — فقط روی پاسخ مشتری؛ کش مشترک را آلوده نمی‌کند */ protected function personalizeLastPriceForCustomer(array $payload): array { + // تنظیمات ساعت کاری همیشه تازه از DB — اگر داخل کش قیمت بماند، + // پولینگ بعدی مقدار قبلی را برمی‌گرداند و تغییر پنل عقب برمی‌گردد. + $payload = app(ConfigService::class)->attachTradingHoursSettingsPatch($payload); + $customerId = (int) (auth('customer')->id() ?? 0); if ($customerId <= 0) { return $payload; diff --git a/app/Services/ConfigService.php b/app/Services/ConfigService.php index ad29a9a..25aef4b 100644 --- a/app/Services/ConfigService.php +++ b/app/Services/ConfigService.php @@ -25,6 +25,12 @@ class ConfigService 'trading_hours_by_day', ]; + /** + * فقط این منابع «آخرین قیمت ذخیره‌شده» هستند؛ باقی price_source (montzar، zaarbox، + * main_product، tala_ir و ...) مظنهٔ زنده حساب می‌شود. + */ + public const STORED_PRICE_SOURCES = ['database_last']; + /** * حداکثر انحراف مجاز مظنه نسبت به آخرین قیمت معتبر DB. * پرش اشتباه provider (مثل ۱۶۸M ماندهٔ montzar روی مظنهٔ ~۲۲۰M) را رد می‌کند. @@ -64,14 +70,15 @@ class ConfigService } /** - * مقدار setting؛ اول سوپرادمین، بعد DB لوکال، بعد price_providers.php. - * برای کلیدهای can_edit=0 همیشه از این مسیر (یا ConfigService) بخوانید — نه Setting::where مستقیم. + * مقدار setting: فقط دسترسی‌های سوپرادمین از overlay؛ بقیه از DB لوکال / فایل. */ public function get(string $key, $default = null) { - $fromSa = $this->valueFromSuperAdmin($key, $default); - if ($fromSa !== '__sa_miss__') { - return $fromSa; + if (SuperAdminProgramSettingsOverlay::isSuperAdminOwned($key)) { + $fromSa = $this->valueFromSuperAdmin($key, $default); + if ($fromSa !== '__sa_miss__') { + return $fromSa; + } } try { @@ -784,6 +791,46 @@ class ConfigService || (int) ($payload['price'] ?? 0) > 0; } + /** + * مظنهٔ زنده داریم یا قیمت از آخرین ردیف prices آمده؟ + * بدون این تفکیک، fallback دیتابیس مثل مظنهٔ زنده دیده می‌شود و + * «نمایش آخرین قیمت ذخیره‌شده» خاموش هم قیمت نشان می‌دهد. + * + * @param array $payload + */ + public function payloadHasLiveQuote(array $payload): bool + { + if (! $this->payloadHasDisplayablePrice($payload)) { + return false; + } + + $source = strtolower(trim((string) ($payload['price_source'] ?? ''))); + if ($source !== '') { + return ! in_array($source, self::STORED_PRICE_SOURCES, true); + } + + // بدون برچسب منبع، فقط عددِ مرجع زنده قابل اتکاست + return (int) ($payload['montzar'] ?? 0) > 0 + || (int) ($payload['zaarbox'] ?? 0) > 0 + || (int) ($payload['main_product'] ?? 0) > 0; + } + + /** + * قیمت‌ها را صفر می‌کند تا هیچ کلاینتی مقدار مانده را نشان ندهد. + * + * @param array $payload + * @return array + */ + public function clearPayloadDisplayPrices(array $payload): array + { + $payload['sell'] = 0; + $payload['buy'] = 0; + $payload['price_last'] = 0; + $payload['price'] = 0; + + return $payload; + } + /** * آخرین قیمت جدول prices را روی payload می‌گذارد — وقتی مظنه زنده نیست و show_last_price روشن است. * @@ -807,7 +854,8 @@ class ConfigService $payload['buy'] = $trade['buy']; $payload['price_last'] = $trade['priceLast']; $payload['price'] = (int) $priceValue; - $payload['price_source'] = $payload['price_source'] ?? 'database_last'; + // قیمت از DB آمد — برچسب منبع قبلی باید پاک شود وگرنه دفعهٔ بعد «زنده» حساب می‌شود + $payload['price_source'] = 'database_last'; return $payload; } @@ -823,29 +871,39 @@ class ConfigService */ public function applyHomePriceDisplayState(array $payload, ?Carbon $now = null): array { - $outside = $this->isOutsideTradingHours($now); - $showWhenClosed = $this->shouldShowHomePriceWhenClosed(); - $showLast = $this->shouldShowLastSavedPrice(); - - if ($outside) { + if ($this->isOutsideTradingHours($now)) { $payload['closed'] = true; - if ($showWhenClosed) { - $payload = $this->fillPayloadFromLastStoredPrice($payload); - $payload['status'] = $this->payloadHasDisplayablePrice($payload) ? 'open' : 'no_quote'; + + if (! $this->shouldShowHomePriceWhenClosed()) { + $payload = $this->clearPayloadDisplayPrices($payload); + $payload['status'] = 'closed'; return $payload; } - $payload['status'] = 'closed'; + $payload = $this->fillPayloadFromLastStoredPrice($payload); + $payload['status'] = $this->payloadHasDisplayablePrice($payload) ? 'open' : 'no_quote'; return $payload; } $payload['closed'] = false; - if ($showLast) { - $payload = $this->fillPayloadFromLastStoredPrice($payload); + + if ($this->payloadHasLiveQuote($payload)) { + $payload['status'] = 'open'; + + return $payload; } - $payload['status'] = $this->payloadHasDisplayablePrice($payload) ? 'open' : 'no_quote'; + + if ($this->shouldShowLastSavedPrice()) { + $payload = $this->fillPayloadFromLastStoredPrice($payload); + $payload['status'] = $this->payloadHasDisplayablePrice($payload) ? 'open' : 'no_quote'; + + return $payload; + } + + $payload = $this->clearPayloadDisplayPrices($payload); + $payload['status'] = 'no_quote'; return $payload; } @@ -911,16 +969,22 @@ class ConfigService $patch = $this->clientTradingHoursSettings(); $cache = app(LivePriceCacheService::class); - $payload = $cache->get() ?? $cache->buildFromDatabase(); + + // کش ممکن است قیمت صفرشدهٔ حالت قبل باشد؛ فقط وقتی مظنهٔ زنده دارد قابل استفاده است + $cached = $cache->get(); + $payload = is_array($cached) && $this->payloadHasLiveQuote($cached) + ? $cached + : $cache->buildFromDatabase(); + $payload = $this->applyHomePriceDisplayState($payload); $payload = app(ZaarboxPriceService::class)->attachGoldOunceToPayload($payload); - $payload['settings_patch'] = $patch; + // settings_patch وارد کش نمی‌شود؛ رویداد settings مقدار تازه را می‌برد + unset($payload['settings_patch']); $cache->replaceBody($payload); - $stream = app(PriceStreamService::class); if ($patch !== []) { - $stream->publish('settings', $patch); + app(PriceStreamService::class)->publish('settings', $patch); } app(PriceBroadcastService::class)->broadcastAfterPriceUpdate($payload, true); diff --git a/app/Services/LivePriceCacheService.php b/app/Services/LivePriceCacheService.php index baec03a..fad52bf 100644 --- a/app/Services/LivePriceCacheService.php +++ b/app/Services/LivePriceCacheService.php @@ -115,6 +115,7 @@ class LivePriceCacheService 'zaarbox' => 0, 'closed' => false, 'status' => 'no_quote', + 'price_source' => '', 'time' => now(), ]; } else { @@ -151,20 +152,25 @@ class LivePriceCacheService 'zaarbox' => $zaarboxSource, 'closed' => false, 'status' => 'open', + 'price_source' => $priceSource, 'time' => now(), ]; } - $payload['up'] = $config->resolvedDailyBaseExtremes()['up'] > 0 - ? [ - 'sell' => max(0, (int) ceil($config->resolvedDailyBaseExtremes()['down'] - $config->displaySellPriceDiffRial())), - 'buy' => (int) ceil($config->resolvedDailyBaseExtremes()['up'] + $config->displayBuyPriceDiffRial()), - ] - : ['sell' => $payload['sell'] ?? 0, 'buy' => $payload['buy'] ?? 0]; $payload['last_historical'] = $display->resolveHistoricalBasePrice(); $payload = $config->applyGlobalTradingHoursToPayload($payload); + $extremes = $config->resolvedDailyBaseExtremes(); + $payload['up'] = ($payload['status'] ?? 'open') !== 'open' + ? ['sell' => 0, 'buy' => 0] + : ($extremes['up'] > 0 + ? [ + 'sell' => max(0, (int) ceil($extremes['down'] - $config->displaySellPriceDiffRial())), + 'buy' => (int) ceil($extremes['up'] + $config->displayBuyPriceDiffRial()), + ] + : ['sell' => $payload['sell'] ?? 0, 'buy' => $payload['buy'] ?? 0]); + return app(ZaarboxPriceService::class)->attachGoldOunceToPayload($payload); } } diff --git a/app/Services/SuperAdminProgramSettingsClient.php b/app/Services/SuperAdminProgramSettingsClient.php index bf28fe6..61ed3a3 100644 --- a/app/Services/SuperAdminProgramSettingsClient.php +++ b/app/Services/SuperAdminProgramSettingsClient.php @@ -6,13 +6,13 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; /** - * خواندن تنظیمات پیشرفته از سوپرادمین (منبع حقیقت can_edit=0). + * خواندن ۱۴ دسترسی سوپرادمین (منبع حقیقت can_edit=0 برای قابلیت‌ها). * * Public اول (دامنه‌محور، بدون وابستگی به HMAC): GET {origin}/api/public/program-settings?domain= * Full (با HMAC/Bearer): GET {origin}/api/remote/program-settings * - * Public برای همین ۱۴ کلید مالکیت سوپرادمین کافی است؛ اگر remote اول باشد و replica عقب بماند، - * سوییچ‌هایی مثل enable_automation روی false کهنه می‌مانند. + * Overlay فقط کلیدهای SUPERADMIN_OWNED_KEYS را نگه می‌دارد؛ پاسخ remote ممکن است + * کلیدهای دیگری هم برگرداند ولی آن‌ها روی tenant اعمال نمی‌شوند. */ class SuperAdminProgramSettingsClient { diff --git a/app/Services/SuperAdminProgramSettingsOverlay.php b/app/Services/SuperAdminProgramSettingsOverlay.php index 32062ad..b90a2dd 100644 --- a/app/Services/SuperAdminProgramSettingsOverlay.php +++ b/app/Services/SuperAdminProgramSettingsOverlay.php @@ -11,21 +11,16 @@ use Illuminate\Support\Facades\Cache; class SuperAdminProgramSettingsOverlay { /** - * آخرین پاسخ سالم سوپرادمین. - * - * کلیدهای can_edit=0 مالکشان سوپرادمین است و کپی‌شان در جدول settings لوکال - * ممکن است ماندهٔ deploy باشد. بدون این کش، یک timeout لحظه‌ای باعث می‌شود - * ConfigService به آن کپی سقوط کند — مثلاً price_provider از zaarbox به montzar - * می‌پرید و مظنهٔ قدیمی montzar_live_price به‌جای قیمت زنده نمایش داده می‌شد. + * آخرین پاسخ سالم سوپرادمین — فقط کلیدهای دسترسی (SUPERADMIN_OWNED_KEYS). + * پاسخ remote ممکن است کلیدهای دیگری هم داشته باشد؛ آن‌ها ذخیره و اعمال نمی‌شوند. */ private const LAST_GOOD_CACHE_KEY = 'superadmin:program_settings:last_good'; private const LAST_GOOD_CACHE_MINUTES = 1440; /** - * کلیدهایی که فقط سوپرادمین ویرایش می‌کند (پنل خانگی 403). - * این لیست فیلتر overlay نیست — برای load از blacklist زیر استفاده می‌شود - * تا مثلاً price_provider از سوپرادمین/کش last-good حذف نشود. + * فقط این ۱۴ دسترسی از سوپرادمین خوانده می‌شود. + * هر کلید دیگری (قیمت، ساعت کاری، برندینگ، …) مال پنل خانگی است. */ public const SUPERADMIN_OWNED_KEYS = [ 'customer_direct_sms', @@ -185,32 +180,47 @@ class SuperAdminProgramSettingsOverlay return null; } + $map = self::ownedItemsMap($items); + self::$memo = $map; + + return $map; + } + + /** + * فقط کلیدهای دسترسی سوپرادمین — بقیهٔ پاسخ remote دور ریخته می‌شود. + * + * @param array> $items + * @return array> + */ + public static function ownedItemsMap(array $items): array + { $map = []; foreach ($items as $row) { if (! is_array($row)) { continue; } $key = trim((string) ($row['key'] ?? '')); - // blacklist: فقط کلیدهای لوکال/runtime را دور بریز — نه whitelist قابلیت‌ها - // (whitelist قبلی price_provider را حذف می‌کرد و با قطعی SA به montzar لوکال می‌پرید) - if ($key === '' || self::isLocalOwned($key) || self::isRuntime($key)) { + if ($key === '' || ! self::isSuperAdminOwned($key)) { continue; } $map[$key] = $row; } - self::$memo = $map; - return $map; } /** @param array> $items */ private function rememberLastGood(array $items): void { + $owned = array_values(self::ownedItemsMap($items)); + if ($owned === []) { + return; + } + try { Cache::put( self::LAST_GOOD_CACHE_KEY, - $items, + $owned, now()->addMinutes(self::LAST_GOOD_CACHE_MINUTES) ); } catch (\Throwable $e) { @@ -255,7 +265,7 @@ class SuperAdminProgramSettingsOverlay return null; } $key = trim($key); - if ($key === '' || ! array_key_exists($key, $map)) { + if ($key === '' || ! self::isSuperAdminOwned($key) || ! array_key_exists($key, $map)) { return null; } @@ -267,9 +277,14 @@ class SuperAdminProgramSettingsOverlay return in_array($key, self::RUNTIME_KEYS, true); } + /** + * کلیدهای صفحهٔ «ساعت کاری» هم مالکشان پنل خانگی است — اگر از overlay خوانده شوند + * مقدار ذخیره‌شده در پنل هیچ‌وقت روی قیمت کاربر اعمال نمی‌شود. + */ public static function isLocalOwned(string $key): bool { - return in_array($key, self::LOCAL_OWNED_KEYS, true); + return in_array($key, self::LOCAL_OWNED_KEYS, true) + || in_array($key, ConfigService::TRADING_HOURS_CLIENT_KEYS, true); } public static function isSuperAdminOwned(string $key): bool diff --git a/tests/Unit/ConfigServiceTradingHoursTest.php b/tests/Unit/ConfigServiceTradingHoursTest.php index 6dc5b0f..f9f1bf1 100644 --- a/tests/Unit/ConfigServiceTradingHoursTest.php +++ b/tests/Unit/ConfigServiceTradingHoursTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit; use App\Services\ConfigService; +use App\Services\SuperAdminProgramSettingsOverlay; use Carbon\Carbon; use Mockery; use Tests\TestCase; @@ -119,6 +120,9 @@ class ConfigServiceTradingHoursTest extends TestCase $this->assertTrue($result['closed']); $this->assertSame('closed', $result['status']); + $this->assertSame(0, $result['buy']); + $this->assertSame(0, $result['sell']); + $this->assertSame(0, $result['price']); Carbon::setTestNow(); } @@ -189,14 +193,87 @@ class ConfigServiceTradingHoursTest extends TestCase 'sell' => 100, 'buy' => 110, 'price' => 105, + 'price_source' => 'montzar', + 'montzar' => 105, ]); $this->assertFalse($result['closed']); $this->assertSame('open', $result['status']); + $this->assertSame(110, $result['buy']); Carbon::setTestNow(); } + /** + * سناریوی گزارش‌شده: مظنه زنده قطع است و قیمت از آخرین ردیف prices آمده، + * ولی «نمایش آخرین قیمت ذخیره‌شده» خاموش است → نباید قیمت نشان داده شود. + */ + public function test_stored_price_is_hidden_when_show_last_price_is_off(): void + { + $cfg = $this->cfg([ + 'close_price' => 'false', + 'show_last_price' => 'false', + 'show_home_price_when_closed' => 'false', + ]); + + $result = $cfg->applyHomePriceDisplayState([ + 'sell' => 168336410, + 'buy' => 168798112, + 'price' => 168798112, + 'price_last' => 168798112, + 'price_source' => 'database_last', + 'montzar' => 0, + 'zaarbox' => 0, + 'main_product' => 0, + ]); + + $this->assertFalse($result['closed']); + $this->assertSame('no_quote', $result['status']); + $this->assertSame(0, $result['buy']); + $this->assertSame(0, $result['sell']); + $this->assertSame(0, $result['price']); + $this->assertSame(0, $result['price_last']); + } + + public function test_live_quote_detection_ignores_database_fallback(): void + { + $cfg = $this->cfg([]); + + $this->assertTrue($cfg->payloadHasLiveQuote([ + 'buy' => 110, + 'price_source' => 'montzar', + ])); + + $this->assertFalse($cfg->payloadHasLiveQuote([ + 'buy' => 110, + 'price_source' => 'database_last', + ])); + + // منابع زندهٔ دیگر مثل tala_ir نباید «ذخیره‌شده» حساب شوند + $this->assertTrue($cfg->payloadHasLiveQuote([ + 'buy' => 110, + 'price_source' => 'tala_ir', + ])); + + // بدون برچسب منبع، عددِ مرجع زنده تصمیم می‌گیرد + $this->assertTrue($cfg->payloadHasLiveQuote([ + 'buy' => 110, + 'zaarbox' => 105, + ])); + + $this->assertFalse($cfg->payloadHasLiveQuote([ + 'buy' => 110, + 'montzar' => 0, + 'zaarbox' => 0, + 'main_product' => 0, + ])); + + $this->assertFalse($cfg->payloadHasLiveQuote([ + 'buy' => 0, + 'price_source' => 'montzar', + ])); + } + public function test_trading_hours_setting_keys(): void { $cfg = new ConfigService(); @@ -206,4 +283,19 @@ class ConfigServiceTradingHoursTest extends TestCase $this->assertTrue($cfg->isTradingHoursSettingKey('show_home_price_when_closed')); $this->assertFalse($cfg->isTradingHoursSettingKey('name_fa')); } + + /** کلیدهای ساعت کاری باید مالکیت tenant داشته باشند وگرنه پنل اثری روی قیمت ندارد */ + public function test_trading_hours_keys_are_tenant_owned(): void + { + foreach (ConfigService::TRADING_HOURS_CLIENT_KEYS as $key) { + $this->assertTrue( + SuperAdminProgramSettingsOverlay::isLocalOwned($key), + "{$key} باید local-owned باشد" + ); + $this->assertFalse( + SuperAdminProgramSettingsOverlay::isSuperAdminOwned($key), + "{$key} نباید سوپرادمین-اونر باشد" + ); + } + } } diff --git a/tests/Unit/SuperAdminProgramSettingsOverlayTest.php b/tests/Unit/SuperAdminProgramSettingsOverlayTest.php new file mode 100644 index 0000000..a687d2b --- /dev/null +++ b/tests/Unit/SuperAdminProgramSettingsOverlayTest.php @@ -0,0 +1,79 @@ +assertSame($expected, SuperAdminProgramSettingsOverlay::SUPERADMIN_OWNED_KEYS); + $this->assertCount(14, SuperAdminProgramSettingsOverlay::SUPERADMIN_OWNED_KEYS); + } + + public function test_overlay_keeps_only_access_keys(): void + { + $map = SuperAdminProgramSettingsOverlay::ownedItemsMap([ + ['key' => 'enable_automation', 'value' => 'true'], + ['key' => 'jibit_enabled', 'value' => 'false'], + ['key' => 'price_provider', 'value' => 'zaarbox'], + ['key' => 'show_last_price', 'value' => 'false'], + ['key' => 'close_price', 'value' => 'true'], + ['key' => 'startDay', 'value' => '11:00'], + ['key' => 'endDay', 'value' => '20:30'], + ['key' => 'name_fa', 'value' => 'طلا'], + ['key' => 'montzar_live_price', 'value' => '168000000'], + ['key' => 'tracking_systems', 'value' => '[]'], + ['key' => '', 'value' => 'x'], + 'not-an-array', + ]); + + $this->assertSame(['enable_automation', 'jibit_enabled'], array_keys($map)); + $this->assertSame('true', $map['enable_automation']['value']); + $this->assertArrayNotHasKey('price_provider', $map); + $this->assertArrayNotHasKey('show_last_price', $map); + } + + public function test_tenant_keys_are_never_superadmin_owned(): void + { + $tenantKeys = array_merge( + ConfigService::TRADING_HOURS_CLIENT_KEYS, + SuperAdminProgramSettingsOverlay::RUNTIME_KEYS, + SuperAdminProgramSettingsOverlay::LOCAL_OWNED_KEYS, + [ + 'price_provider', + 'zaarbox_price_key', + 'montzar_base_url', + 'name_fa', + 'name_en', + ] + ); + + foreach ($tenantKeys as $key) { + $this->assertFalse( + SuperAdminProgramSettingsOverlay::isSuperAdminOwned($key), + "{$key} نباید از سوپرادمین خوانده شود" + ); + } + } +}