refactor: extract rate formatting logic to RateService and add tests

هذا الالتزام موجود في:
2025-05-31 15:22:50 +03:00
الأصل b19748ef20
التزام cc4e74f3f1
13 ملفات معدلة مع 278 إضافات و56 حذوفات

عرض الملف

@@ -55,6 +55,8 @@ MAIL_USERNAME=null
MAIL_PASSWORD=null MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
MAIL_ADMIN_ADDRESS="watheq.alshowaiter@gmail.com"
MAIL_ADMIN_NAME="Watheq Alshowaiter"
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=

عرض الملف

@@ -8,6 +8,8 @@ to be added
## Plan ## Plan
- [x] Add html view to show the currency data - [x] Add html view to show the currency data
- [x] Save command data to database - [x] Save command data to database
- [ ] email when failing fetching currency using resen
- [ ] caching for fetching command (6 hours), and for the controller (1 hour)
- [ ] make proper pagination - [ ] make proper pagination
- [ ] save tabs state as query param - [ ] save tabs state as query param
- [ ] make a complete test for the project - [ ] make a complete test for the project

عرض الملف

@@ -41,9 +41,9 @@ class FetchCurrencyCommand extends Command
'SAR' => Currency::where('code', 'SAR')->first(), 'SAR' => Currency::where('code', 'SAR')->first(),
}; };
$rate = Rate::updateOrCreate([ Rate::updateOrCreate([
'city_id' => $city->id, 'city_id' => $city?->id,
'currency_id' => $currency->id, 'currency_id' => $currency?->id,
'date' => $item['date'], 'date' => $item['date'],
], [ ], [
'buy_price' => $item['price_buy'], 'buy_price' => $item['price_buy'],
@@ -61,6 +61,8 @@ class FetchCurrencyCommand extends Command
'trace' => $e->getTraceAsString(), 'trace' => $e->getTraceAsString(),
]); ]);
// todo mail when failing using resend
return Command::FAILURE; return Command::FAILURE;
} }
} }
@@ -110,7 +112,7 @@ class FetchCurrencyCommand extends Command
if (empty($data)) { if (empty($data)) {
$this->warn('No historical currency data available.'); $this->warn('No historical currency data available.');
return; return [];
} }
$this->info('Last 20 Days Currency Data:'); $this->info('Last 20 Days Currency Data:');

عرض الملف

@@ -4,31 +4,16 @@ namespace App\Http\Controllers;
use App\Models\City; use App\Models\City;
use App\Models\Rate; use App\Models\Rate;
use App\Services\RateService;
class RateController extends Controller class RateController extends Controller
{ {
public function __invoke() public function __invoke( RateService $rateService)
{ {
$rates = $rateService->getFormattedRates();
$supportedCities = City::query()
->whereIn('name', City::supportedCities())
->get()
->sortByDesc(fn ($city) => $city->name === 'sanaa')
->values();
$rates = Rate::query()
->with('currency', 'city')
->orderBy('city_id', 'asc')
->orderBy('currency_id', 'asc')
->orderBy('date', 'desc')
->get()
->groupBy(fn ($rate) => $rate->city_id.'-'.$rate->currency_id)
->map(fn ($group) => $group->first())
->groupBy('city_id');
return view('rates', [ return view('rates', [
'rates' => $rates, 'rates' => $rates,
'supportedCities' => $supportedCities,
]); ]);
} }
} }

عرض الملف

@@ -2,6 +2,11 @@
namespace App\Providers; namespace App\Providers;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -19,6 +24,12 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
// DB::prohibitDestructiveCommands(app()->isProduction());
URL::forceHttps(app()->isProduction());
Model::shouldBeStrict(! app()->isProduction());
Date::use(CarbonImmutable::class);
} }
} }

عرض الملف

@@ -0,0 +1,51 @@
<?php
namespace App\Services;
use App\Models\City;
use App\Models\Rate;
class RateService
{
public function getFormattedRates(): array
{
$supportedCities = City::query()
->whereIn('name', City::supportedCities()) // Filter only supported cities
->get()
->sortByDesc(fn($city) => $city->name === 'sanaa') // Sort to put 'sanaa' first
->values(); // Reset array keys to be sequential
// Get the latest exchange rates for each currency in each city
$latestRates = Rate::query()
->with('currency', 'city')
->orderBy('city_id', 'asc')
->orderBy('currency_id', 'asc')
->orderBy('date', 'desc')
->get()
->groupBy(fn($rate) => $rate->city_id . '-' . $rate->currency_id)
->map(fn($group) => $group->first())
->groupBy('city_id');
// Format the rates
$rates = $supportedCities->map(function ($city) use ($latestRates) {
$cityRates = $latestRates->get($city->id, collect())->map(function ($rate) {
return [
'currency' => $rate->currency->name,
'buy_price' => $rate->buy_price,
'sell_price' => $rate->sell_price,
'date' => $rate->date->toDateString(),
'day' => $rate->date->locale('ar')->isoFormat('dddd'),
'last_update' => $rate->updated_at->diffForHumans(),
];
})->values()->toArray();
return [
'city' => $city->label ,
'rates' => $cityRates,
];
})->toArray();
return $rates;
}
}

عرض الملف

@@ -115,4 +115,20 @@ return [
'name' => env('MAIL_FROM_NAME', 'Example'), 'name' => env('MAIL_FROM_NAME', 'Example'),
], ],
/*
|--------------------------------------------------------------------------
| Global "Admin" Email Address
|--------------------------------------------------------------------------
|
| This address is used for sending important system alerts or notifications
| to the administrator. It can be referenced across the app when notifying
| the site admin about system-level issues.
|
*/
'admin' => [
'address' => env('MAIL_ADMIN_ADDRESS'),
'name' => env('MAIL_ADMIN_NAME', 'Admin'),
]
]; ];

عرض الملف

@@ -19,6 +19,14 @@ class CityFactory extends Factory
{ {
return [ return [
'name' => fake()->randomElement(City::supportedCities()), 'name' => fake()->randomElement(City::supportedCities()),
'label' => function ($attributes) {
return match ($attributes['name']) {
City::SANAA => 'صنعاء',
City::ADEN => 'عدن',
default => $attributes['name'],
};
},
]; ];
} }
@@ -26,7 +34,6 @@ class CityFactory extends Factory
{ {
return $this->state([ return $this->state([
'name' => City::SANAA, 'name' => City::SANAA,
'label' => 'صنعاء',
]); ]);
} }
@@ -34,7 +41,6 @@ class CityFactory extends Factory
{ {
return $this->state([ return $this->state([
'name' => City::ADEN, 'name' => City::ADEN,
'label' => 'عدن',
]); ]);
} }
} }

عرض الملف

@@ -16,18 +16,13 @@ class DatabaseSeeder extends Seeder
public function run(): void public function run(): void
{ {
// currencies // currencies
$saudiRial = Currency::factory()->saudiRial()->create();
$usdDollar = Currency::factory()->usdDollar()->create(); $usdDollar = Currency::factory()->usdDollar()->create();
$saudiRial = Currency::factory()->saudiRial()->create();
// cities // cities
$sanaa = City::factory()->sanaa()->create(); $sanaa = City::factory()->sanaa()->create();
$aden = City::factory()->aden()->create(); $aden = City::factory()->aden()->create();
// rates // we will seed rates with commands instead
Rate::factory()->recycle($usdDollar)->recycle($sanaa)->create();
Rate::factory()->recycle($usdDollar)->recycle($aden)->create();
Rate::factory()->recycle($saudiRial)->recycle($sanaa)->create();
Rate::factory()->recycle($saudiRial)->recycle($aden)->create();
} }
} }

عرض الملف

@@ -100,49 +100,39 @@
<h1>أسعار العملات في اليمن</h1> <h1>أسعار العملات في اليمن</h1>
<div class="tabs"> <div class="tabs">
@foreach ($supportedCities as $city) @foreach ($rates as $index => $rate)
<div class="tab {{ $loop->first ? 'active' : '' }}" onclick="showTab(event, '{{ $city->id }}')"> <div class="tab {{ $loop->first ? 'active' : '' }}" onclick="showTab(event, 'city-{{ $index }}')">
<span class="">{{ $city->label }} </span> {{ $rate['city'] }}
</div> </div>
@endforeach @endforeach
</div> </div>
@foreach ($rates as $cityId => $cityRates) @foreach ($rates as $index => $cityRates)
@php <div id="city-{{ $index }}" class="currency-card {{ $loop->first ? 'active' : '' }}">
$city = $supportedCities->firstWhere('id', $cityId); @foreach ($cityRates['rates'] as $rate)
@endphp
<div id="{{ $cityId }}" class="currency-card {{ $loop->first ? 'active' : '' }}">
@foreach ($cityRates as $rate)
<div style="margin-bottom: {{ $loop->last ? '0' : '10px' }}; border-bottom: {{ $loop->last ? 'none' : '1px dashed #ccc' }}; padding-bottom: 10px;"> <div style="margin-bottom: {{ $loop->last ? '0' : '10px' }}; border-bottom: {{ $loop->last ? 'none' : '1px dashed #ccc' }}; padding-bottom: 10px;">
<div class="currency-info"> <div class="currency-info">
<strong>العملة:</strong> <strong>العملة:</strong>
<span>{{ $rate->currency->name }}</span> <span>{{ $rate['currency'] }}</span>
</div> </div>
<div class="currency-info"> <div class="currency-info">
<strong>سعر الشراء:</strong> <strong>سعر الشراء:</strong>
<span>{{ $rate->buy_price }} ريال يمني</span> <span>{{ $rate['buy_price'] }} ريال يمني</span>
</div> </div>
<div class="currency-info"> <div class="currency-info">
<strong>سعر البيع:</strong> <strong>سعر البيع:</strong>
<span>{{ $rate->sell_price }} ريال يمني</span> <span>{{ $rate['sell_price'] }} ريال يمني</span>
</div> </div>
<div class="currency-info"> <div class="currency-info">
<strong>التاريخ:</strong> <strong>التاريخ:</strong>
<span>{{ $rate->date->format('Y-m-d') }}</span> <span>{{ $rate['date'] }} ({{ $rate['day'] }})</span>
</div> </div>
<div class="currency-info">
<strong>اليوم:</strong>
<span>{{ $rate->date->locale('ar')->isoFormat('dddd') }}</span>
</div>
<div class="date-info"> <div class="date-info">
آخر تحديث: {{ $rate->updated_at?->diffForHumans() }} آخر تحديث: {{ $rate['last_update'] }}
</div> </div>
</div> </div>
@endforeach @endforeach
</div> </div>
@endforeach @endforeach
</div> </div>

عرض الملف

@@ -4,4 +4,18 @@ use Illuminate\Support\Facades\Schedule;
Schedule::command('currency:fetch --today') Schedule::command('currency:fetch --today')
->everySixHours() ->everySixHours()
->appendOutputTo(storage_path('logs/currency-fetch.log')); ->appendOutputTo(storage_path('logs/currency-fetch.log'))
->onFailure(function () {
// TODO: set the correct sender email
// TODO: set the correct recipient email
// \Illuminate\Support\Facades\Mail::raw(
// "Currency fetching failed!\n\nCheck the page: https://boqash.com/price-currency\nAnd review the logs for more details.",
// function (\Illuminate\Mail\Message $message) {
// $message
// ->from(config('mail.from.address'))
// ->to(config('mail.admin.address'))
// ->subject('Currency Fetching Failed!');
// }
// );
});

عرض الملف

@@ -0,0 +1,129 @@
<?php
namespace Tests\Feature;
use App\Models\City;
use App\Models\Currency;
use App\Models\Rate;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class RateControllerTest extends TestCase
{
use RefreshDatabase;
public function test_home_page_displays_rates()
{
$sanaa = City::factory()->sanaa()->create();
$aden = City::factory()->aden()->create();
$unsupportedCity = City::factory()->create(['name' => 'taiz']);
$usd = Currency::factory()->create(['code' => 'USD']);
$sar = Currency::factory()->create(['code' => 'SAR']);
// Sanaa rates
$sanaaOldSaudiRate = Rate::factory()
->create([
'city_id' => $sanaa->id,
'currency_id' => $sar->id,
'date' => now()->subDay(),
'buy_price' => 139,
'sell_price' => 140
]);
$sanaaLatestSaudiRate = Rate::factory()
->create([
'city_id' => $sanaa->id,
'currency_id' => $sar->id,
'date' => now(),
'buy_price' => 140,
'sell_price' => 141
]);
$sanaaOldDollarRate = Rate::factory()
->create([
'city_id' => $sanaa->id,
'currency_id' => $usd->id,
'date' => now()->subDay(),
'buy_price' => 251,
'sell_price' => 256
]);
$sanaaLatestDollarRate = Rate::factory()
->create([
'city_id' => $sanaa->id,
'currency_id' => $usd->id,
'date' => now(),
'buy_price' => 256,
'sell_price' => 261
]);
// Aden rates
$adenOldSaudiRate = Rate::factory()->create([
'city_id' => $aden->id,
'currency_id' => $sar->id,
'date' => now()->subDay(),
'buy_price' => 300,
'sell_price' => 305
]);
$adenLatestSaudiRate = Rate::factory()->create([
'city_id' => $aden->id,
'currency_id' => $sar->id,
'date' => now(),
'buy_price' => 305,
'sell_price' => 310
]);
$adenOldDollarRate = Rate::factory()->create([
'city_id' => $aden->id,
'currency_id' => $usd->id,
'date' => now()->subDay(),
'buy_price' => 2222,
'sell_price' => 2227
]);
$adenLatestDollarRate = Rate::factory()->create([
'city_id' => $aden->id,
'currency_id' => $usd->id,
'date' => now(),
'buy_price' => 22223,
'sell_price' => 22224
]);
$unsupportedRate = Rate::factory()->create([
'city_id' => $unsupportedCity->id,
'currency_id' => $usd->id,
'date' => now(),
'buy_price' => 270,
'sell_price' => 275
]);
// Act
$response = $this->get('/');
// Assert
$response->assertStatus(200)
->assertViewIs('rates')
->assertViewHasAll(['rates']);
$viewData = $response->original->getData();
$ratesCities = array_column($viewData['rates'], 'city');
$this->assertContains($sanaa->label, $ratesCities);
$this->assertContains($aden->label, $ratesCities);
$this->assertNotContains($unsupportedCity->label, $ratesCities);
$sanaaRates = $viewData['rates'][0]['rates'];
$this->assertCount(2, $sanaaRates);
$this->assertEquals($sanaaLatestDollarRate->buy_price, $sanaaRates[0]['buy_price']);
$this->assertEquals($sanaaLatestDollarRate->sell_price, $sanaaRates[0]['sell_price']);
$this->assertEquals($sanaaLatestSaudiRate->buy_price, $sanaaRates[1]['buy_price']);
$this->assertEquals($sanaaLatestSaudiRate->sell_price, $sanaaRates[1]['sell_price']);
$adenRates = $viewData['rates'][1]['rates'];
$this->assertCount(2, $adenRates);
$this->assertEquals($adenLatestDollarRate->buy_price, $adenRates[0]['buy_price']);
$this->assertEquals($adenLatestDollarRate->sell_price, $adenRates[0]['sell_price']);
$this->assertEquals($adenLatestSaudiRate->buy_price, $adenRates[1]['buy_price']);
$this->assertEquals($adenLatestSaudiRate->sell_price, $adenRates[1]['sell_price']);
}
}

عرض الملف

@@ -2,6 +2,9 @@
namespace Tests\Unit; namespace Tests\Unit;
use App\Models\City;
use App\Models\Currency;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
use App\Console\Commands\FetchCurrencyCommand; use App\Console\Commands\FetchCurrencyCommand;
@@ -10,6 +13,9 @@ use Mockery;
class FetchCurrencyCommandTest extends TestCase class FetchCurrencyCommandTest extends TestCase
{ {
use RefreshDatabase;
protected $command; protected $command;
protected $currencyService; protected $currencyService;
@@ -27,6 +33,8 @@ class FetchCurrencyCommandTest extends TestCase
public function testHandleWithTodayOption() public function testHandleWithTodayOption()
{ {
$this->seedData();
// Sample data for today's currencies // Sample data for today's currencies
$todayCurrencies = [ $todayCurrencies = [
[ [
@@ -66,6 +74,8 @@ class FetchCurrencyCommandTest extends TestCase
public function testHandleWithoutTodayOption() public function testHandleWithoutTodayOption()
{ {
$this->seedData();
// Sample data for historical currencies // Sample data for historical currencies
$historicalCurrencies = [ $historicalCurrencies = [
[ [
@@ -137,4 +147,13 @@ class FetchCurrencyCommandTest extends TestCase
Mockery::close(); Mockery::close();
parent::tearDown(); parent::tearDown();
} }
private function seedData(): void
{
$sanaa = City::factory()->sanaa()->create();
$aden = City::factory()->aden()->create();
$usdDollar = Currency::factory()->usdDollar()->create();
$saudiRial = Currency::factory()->saudiRial()->create();
}
} }