refactor: extract rate formatting logic to RateService and add tests
هذا الالتزام موجود في:
@@ -55,6 +55,8 @@ MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
MAIL_ADMIN_ADDRESS="watheq.alshowaiter@gmail.com"
|
||||
MAIL_ADMIN_NAME="Watheq Alshowaiter"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
|
||||
@@ -8,6 +8,8 @@ to be added
|
||||
## Plan
|
||||
- [x] Add html view to show the currency data
|
||||
- [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
|
||||
- [ ] save tabs state as query param
|
||||
- [ ] make a complete test for the project
|
||||
|
||||
@@ -41,9 +41,9 @@ class FetchCurrencyCommand extends Command
|
||||
'SAR' => Currency::where('code', 'SAR')->first(),
|
||||
};
|
||||
|
||||
$rate = Rate::updateOrCreate([
|
||||
'city_id' => $city->id,
|
||||
'currency_id' => $currency->id,
|
||||
Rate::updateOrCreate([
|
||||
'city_id' => $city?->id,
|
||||
'currency_id' => $currency?->id,
|
||||
'date' => $item['date'],
|
||||
], [
|
||||
'buy_price' => $item['price_buy'],
|
||||
@@ -61,6 +61,8 @@ class FetchCurrencyCommand extends Command
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
// todo mail when failing using resend
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -110,7 +112,7 @@ class FetchCurrencyCommand extends Command
|
||||
if (empty($data)) {
|
||||
$this->warn('No historical currency data available.');
|
||||
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->info('Last 20 Days Currency Data:');
|
||||
|
||||
@@ -4,31 +4,16 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\City;
|
||||
use App\Models\Rate;
|
||||
use App\Services\RateService;
|
||||
|
||||
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', [
|
||||
'rates' => $rates,
|
||||
'supportedCities' => $supportedCities,
|
||||
return view('rates', [
|
||||
'rates' => $rates,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
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;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -19,6 +24,12 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
DB::prohibitDestructiveCommands(app()->isProduction());
|
||||
|
||||
URL::forceHttps(app()->isProduction());
|
||||
|
||||
Model::shouldBeStrict(! app()->isProduction());
|
||||
|
||||
Date::use(CarbonImmutable::class);
|
||||
}
|
||||
}
|
||||
|
||||
51
app/Services/RateService.php
Normal file
51
app/Services/RateService.php
Normal file
@@ -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'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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 [
|
||||
'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([
|
||||
'name' => City::SANAA,
|
||||
'label' => 'صنعاء',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -34,7 +41,6 @@ class CityFactory extends Factory
|
||||
{
|
||||
return $this->state([
|
||||
'name' => City::ADEN,
|
||||
'label' => 'عدن',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,18 +16,13 @@ class DatabaseSeeder extends Seeder
|
||||
public function run(): void
|
||||
{
|
||||
// currencies
|
||||
$saudiRial = Currency::factory()->saudiRial()->create();
|
||||
$usdDollar = Currency::factory()->usdDollar()->create();
|
||||
$saudiRial = Currency::factory()->saudiRial()->create();
|
||||
|
||||
// cities
|
||||
$sanaa = City::factory()->sanaa()->create();
|
||||
$aden = City::factory()->aden()->create();
|
||||
|
||||
// rates
|
||||
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();
|
||||
// we will seed rates with commands instead
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,49 +100,39 @@
|
||||
<h1>أسعار العملات في اليمن</h1>
|
||||
|
||||
<div class="tabs">
|
||||
@foreach ($supportedCities as $city)
|
||||
<div class="tab {{ $loop->first ? 'active' : '' }}" onclick="showTab(event, '{{ $city->id }}')">
|
||||
<span class="">{{ $city->label }} </span>
|
||||
@foreach ($rates as $index => $rate)
|
||||
<div class="tab {{ $loop->first ? 'active' : '' }}" onclick="showTab(event, 'city-{{ $index }}')">
|
||||
{{ $rate['city'] }}
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@foreach ($rates as $cityId => $cityRates)
|
||||
@foreach ($rates as $index => $cityRates)
|
||||
|
||||
@php
|
||||
$city = $supportedCities->firstWhere('id', $cityId);
|
||||
@endphp
|
||||
<div id="{{ $cityId }}" class="currency-card {{ $loop->first ? 'active' : '' }}">
|
||||
@foreach ($cityRates as $rate)
|
||||
<div id="city-{{ $index }}" class="currency-card {{ $loop->first ? 'active' : '' }}">
|
||||
@foreach ($cityRates['rates'] as $rate)
|
||||
<div style="margin-bottom: {{ $loop->last ? '0' : '10px' }}; border-bottom: {{ $loop->last ? 'none' : '1px dashed #ccc' }}; padding-bottom: 10px;">
|
||||
<div class="currency-info">
|
||||
<strong>العملة:</strong>
|
||||
<span>{{ $rate->currency->name }}</span>
|
||||
<span>{{ $rate['currency'] }}</span>
|
||||
</div>
|
||||
<div class="currency-info">
|
||||
<strong>سعر الشراء:</strong>
|
||||
<span>{{ $rate->buy_price }} ريال يمني</span>
|
||||
<span>{{ $rate['buy_price'] }} ريال يمني</span>
|
||||
</div>
|
||||
<div class="currency-info">
|
||||
<strong>سعر البيع:</strong>
|
||||
<span>{{ $rate->sell_price }} ريال يمني</span>
|
||||
<span>{{ $rate['sell_price'] }} ريال يمني</span>
|
||||
</div>
|
||||
<div class="currency-info">
|
||||
<strong>التاريخ:</strong>
|
||||
<span>{{ $rate->date->format('Y-m-d') }}</span>
|
||||
<span>{{ $rate['date'] }} ({{ $rate['day'] }})</span>
|
||||
</div>
|
||||
<div class="currency-info">
|
||||
<strong>اليوم:</strong>
|
||||
<span>{{ $rate->date->locale('ar')->isoFormat('dddd') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="date-info">
|
||||
آخر تحديث: {{ $rate->updated_at?->diffForHumans() }}
|
||||
آخر تحديث: {{ $rate['last_update'] }}
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@@ -4,4 +4,18 @@ use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::command('currency:fetch --today')
|
||||
->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!');
|
||||
// }
|
||||
// );
|
||||
});
|
||||
129
tests/Feature/RateControllerTest.php
Normal file
129
tests/Feature/RateControllerTest.php
Normal file
@@ -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;
|
||||
|
||||
use App\Models\City;
|
||||
use App\Models\Currency;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
use App\Console\Commands\FetchCurrencyCommand;
|
||||
@@ -10,6 +13,9 @@ use Mockery;
|
||||
|
||||
class FetchCurrencyCommandTest extends TestCase
|
||||
{
|
||||
|
||||
use RefreshDatabase;
|
||||
|
||||
protected $command;
|
||||
protected $currencyService;
|
||||
|
||||
@@ -27,6 +33,8 @@ class FetchCurrencyCommandTest extends TestCase
|
||||
|
||||
public function testHandleWithTodayOption()
|
||||
{
|
||||
$this->seedData();
|
||||
|
||||
// Sample data for today's currencies
|
||||
$todayCurrencies = [
|
||||
[
|
||||
@@ -66,6 +74,8 @@ class FetchCurrencyCommandTest extends TestCase
|
||||
|
||||
public function testHandleWithoutTodayOption()
|
||||
{
|
||||
$this->seedData();
|
||||
|
||||
// Sample data for historical currencies
|
||||
$historicalCurrencies = [
|
||||
[
|
||||
@@ -137,4 +147,13 @@ class FetchCurrencyCommandTest extends TestCase
|
||||
Mockery::close();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم