From cc4e74f3f1b395a95ecc59e924afaf7c19d7c5ef Mon Sep 17 00:00:00 2001 From: Watheq Alshowaiter Date: Sat, 31 May 2025 15:22:50 +0300 Subject: [PATCH] refactor: extract rate formatting logic to RateService and add tests --- .env.example | 2 + README.md | 2 + app/Console/Commands/FetchCurrencyCommand.php | 10 +- app/Http/Controllers/RateController.php | 25 +--- app/Providers/AppServiceProvider.php | 13 +- app/Services/RateService.php | 51 +++++++ config/mail.php | 16 +++ database/factories/CityFactory.php | 10 +- database/seeders/DatabaseSeeder.php | 9 +- resources/views/rates.blade.php | 32 ++--- routes/console.php | 16 ++- tests/Feature/RateControllerTest.php | 129 ++++++++++++++++++ tests/Unit/FetchCurrencyCommandTest.php | 19 +++ 13 files changed, 278 insertions(+), 56 deletions(-) create mode 100644 app/Services/RateService.php create mode 100644 tests/Feature/RateControllerTest.php diff --git a/.env.example b/.env.example index 4124a82..07684f0 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index 0aac7c0..6931f69 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/Console/Commands/FetchCurrencyCommand.php b/app/Console/Commands/FetchCurrencyCommand.php index 92bc576..d05a422 100644 --- a/app/Console/Commands/FetchCurrencyCommand.php +++ b/app/Console/Commands/FetchCurrencyCommand.php @@ -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:'); diff --git a/app/Http/Controllers/RateController.php b/app/Http/Controllers/RateController.php index 1f49baf..a208c58 100644 --- a/app/Http/Controllers/RateController.php +++ b/app/Http/Controllers/RateController.php @@ -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, ]); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..c78051a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); } } diff --git a/app/Services/RateService.php b/app/Services/RateService.php new file mode 100644 index 0000000..7882d25 --- /dev/null +++ b/app/Services/RateService.php @@ -0,0 +1,51 @@ +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; + } +} diff --git a/config/mail.php b/config/mail.php index 0034532..ca938c6 100644 --- a/config/mail.php +++ b/config/mail.php @@ -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'), + ] + ]; diff --git a/database/factories/CityFactory.php b/database/factories/CityFactory.php index 36a420e..90a552f 100644 --- a/database/factories/CityFactory.php +++ b/database/factories/CityFactory.php @@ -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' => 'عدن', ]); } } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 4860bc5..4bfc25e 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -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 } } diff --git a/resources/views/rates.blade.php b/resources/views/rates.blade.php index cb1479f..f2dd994 100644 --- a/resources/views/rates.blade.php +++ b/resources/views/rates.blade.php @@ -100,49 +100,39 @@

أسعار العملات في اليمن

- @foreach ($supportedCities as $city) -
- {{ $city->label }} + @foreach ($rates as $index => $rate) +
+ {{ $rate['city'] }}
@endforeach
- @foreach ($rates as $cityId => $cityRates) + @foreach ($rates as $index => $cityRates) - @php - $city = $supportedCities->firstWhere('id', $cityId); - @endphp -
- @foreach ($cityRates as $rate) +
+ @foreach ($cityRates['rates'] as $rate)
العملة: - {{ $rate->currency->name }} + {{ $rate['currency'] }}
سعر الشراء: - {{ $rate->buy_price }} ريال يمني + {{ $rate['buy_price'] }} ريال يمني
سعر البيع: - {{ $rate->sell_price }} ريال يمني + {{ $rate['sell_price'] }} ريال يمني
التاريخ: - {{ $rate->date->format('Y-m-d') }} + {{ $rate['date'] }} ({{ $rate['day'] }})
-
- اليوم: - {{ $rate->date->locale('ar')->isoFormat('dddd') }} -
-
- آخر تحديث: {{ $rate->updated_at?->diffForHumans() }} + آخر تحديث: {{ $rate['last_update'] }}
@endforeach - -
@endforeach
diff --git a/routes/console.php b/routes/console.php index 6fc0303..6f23fdc 100644 --- a/routes/console.php +++ b/routes/console.php @@ -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!'); + // } + // ); + }); \ No newline at end of file diff --git a/tests/Feature/RateControllerTest.php b/tests/Feature/RateControllerTest.php new file mode 100644 index 0000000..34f16f4 --- /dev/null +++ b/tests/Feature/RateControllerTest.php @@ -0,0 +1,129 @@ +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']); + } +} diff --git a/tests/Unit/FetchCurrencyCommandTest.php b/tests/Unit/FetchCurrencyCommandTest.php index c09c8cc..fbc3a99 100644 --- a/tests/Unit/FetchCurrencyCommandTest.php +++ b/tests/Unit/FetchCurrencyCommandTest.php @@ -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(); + } }