diff --git a/app/Console/Commands/FetchCurrencyCommand.php b/app/Console/Commands/FetchCurrencyCommand.php new file mode 100644 index 0000000..90574d4 --- /dev/null +++ b/app/Console/Commands/FetchCurrencyCommand.php @@ -0,0 +1,102 @@ +info('Fetching currency data...'); + + try { + if ($this->option('today')) { + $this->fetchTodayData($currencyService); + } else { + $this->fetchHistoricalData($currencyService); + } + + $this->info('Currency data fetched successfully!'); + return Command::SUCCESS; + } catch (\Exception $e) { + $this->error('Failed to fetch currency data: ' . $e->getMessage()); + Log::error('Currency fetch command failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + return Command::FAILURE; + } + } + + /** + * Fetch and display today's currency data. + * + * @return void + */ + protected function fetchTodayData(CurrencyService $currencyService) + { + $data = $currencyService->getTodayCurrencies(); + + if (empty($data)) { + $this->warn('No currency data available for today.'); + return; + } + + $this->info('Today\'s Currency Data:'); + $this->table( + ['Currency', 'City', 'Buy Price', 'Sell Price', 'Date', 'Day'], + array_map(function ($item) { + return [ + $item['currency'], + $item['city'], + $item['price_buy'], + $item['price_sell'], + $item['date'], + $item['day'] + ]; + }, $data) + ); + } + + /** + * Fetch and display historical currency data. + * + * @return void + */ + protected function fetchHistoricalData(CurrencyService $currencyService) + { + $data = $currencyService->getLastTwentyDays(); + + if (empty($data)) { + $this->warn('No historical currency data available.'); + return; + } + + $this->info('Last 20 Days Currency Data:'); + $this->table( + ['Currency', 'City', 'Buy Price', 'Sell Price', 'Date', 'Day'], + array_map(function ($item) { + return [ + $item['currency'], + $item['city'], + $item['price_buy'], + $item['price_sell'], + $item['date'], + $item['day'] + ]; + }, $data) + ); + } +} diff --git a/app/Services/CurrencyService.php b/app/Services/CurrencyService.php new file mode 100644 index 0000000..c739b29 --- /dev/null +++ b/app/Services/CurrencyService.php @@ -0,0 +1,177 @@ +request('GET', $this->url); + $table = $crawler->filter('table')->first(); + $rows = $table->filter('tbody')->filter('tr'); + + $results = []; + + foreach ($rows as $row) { + $rowCrawler = new Crawler($row); + $cells = $rowCrawler->filter('td'); + + if ($cells->count() >= 5) { + $currency = $this->getCurrency($cells->eq(0)->text()); + $city = $this->getCity($cells->eq(1)->text()); + $priceBuy = $this->getPrice($cells->eq(2)->text()); + $priceSell = $this->getPrice($cells->eq(3)->text()); + $date = $this->getDate($cells->eq(4)->text()); + + $results[] = [ + 'currency' => $currency, + 'city' => $city, + 'price_buy' => $priceBuy, + 'price_sell' => $priceSell, + 'date' => $date->format('Y-m-d'), + 'day' => $date->format('l'), + ]; + } + } + + return $results; + } catch (\Exception $e) { + Log::error('Failed to fetch currency data for last twenty days', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return []; + } + } + + /** + * Get today's currency data for Sanaa and Aden + * + * @return array + */ + public function getTodayCurrencies(): array + { + try { + $allData = $this->getLastTwentyDays(); + + $today = Carbon::today(); + + // Filter for today's data and specific cities + $todayData = array_filter($allData, function ($item) use ($today) { + return $item['date'] === $today->format('Y-m-d') && in_array($item['city'], $this->cities); + }); + + // If no data for today, get the most recent data + if (empty($todayData)) { + // Sort by date descending + usort($allData, function ($a, $b) { + return strtotime($b['date']) - strtotime($a['date']); + }); + + // Get the most recent date + $mostRecentDate = !empty($allData) ? $allData[0]['date'] : null; + + if ($mostRecentDate) { + $todayData = array_filter($allData, function ($item) use ($mostRecentDate) { + return $item['date'] === $mostRecentDate && in_array($item['city'], $this->cities); + }); + } + } + + return array_values($todayData); + } catch (\Exception $e) { + Log::error('Failed to fetch today\'s currency data', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return []; + } + } + + /** + * Convert currency text to code + * + * @param string $currencyText + * @return string + */ + private function getCurrency(string $currencyText): string + { + $currency = trim($currencyText); + + return match ($currency) { + 'دولار أمريكي' => 'USD', + 'ريال سعودي' => 'SAR', + default => $currency + }; + } + + /** + * Clean and return city name + * + * @param string $cityText + * @return string + */ + private function getCity(string $cityText): string + { + $city = trim($cityText); + + return match ($city) { + 'صنعاء' => "Sanaa", + 'عدن' => 'Aden', + }; + } + + /** + * Extract and clean price value + * + * @param string $priceText + * @return float + */ + private function getPrice(string $priceText): float + { + // Remove special characters + $priceText = str_replace(['●', '▲', '▼', 'ريال', ','], '', $priceText); + + // Extract only numbers and decimal point + $priceText = preg_replace('/[^0-9.]/', '', $priceText); + + return (float) $priceText; + } + + /** + * Parse date text to Carbon instance + * + * @param string $dateText + * @return Carbon + */ + private function getDate(string $dateText): Carbon + { + return Carbon::parse($dateText); + } +} diff --git a/composer.json b/composer.json index dfe9c82..e53dc75 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,9 @@ "require": { "php": "^8.2", "laravel/framework": "^12.0", - "laravel/tinker": "^2.10.1" + "laravel/tinker": "^2.10.1", + "symfony/browser-kit": "^7.2", + "symfony/http-client": "^7.2" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 357db6c..49155ef 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "88970a0117c062eed55fa8728fc43833", + "content-hash": "b5ccaeb33793a0fd614677d4cdb3d4c9", "packages": [ { "name": "brick/math", @@ -2006,6 +2006,73 @@ ], "time": "2024-12-08T08:18:47+00:00" }, + { + "name": "masterminds/html5", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", + "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.9.0" + }, + "time": "2024-03-31T07:05:07+00:00" + }, { "name": "monolog/monolog", "version": "3.9.0", @@ -3286,6 +3353,74 @@ ], "time": "2024-04-27T21:32:50+00:00" }, + { + "name": "symfony/browser-kit", + "version": "v7.2.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/browser-kit.git", + "reference": "8ce0ee23857d87d5be493abba2d52d1f9e49da61" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/8ce0ee23857d87d5be493abba2d52d1f9e49da61", + "reference": "8ce0ee23857d87d5be493abba2d52d1f9e49da61", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/dom-crawler": "^6.4|^7.0" + }, + "require-dev": { + "symfony/css-selector": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\BrowserKit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/browser-kit/tree/v7.2.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-02-14T14:27:24+00:00" + }, { "name": "symfony/clock", "version": "v7.2.0", @@ -3585,6 +3720,73 @@ ], "time": "2024-09-25T14:20:29+00:00" }, + { + "name": "symfony/dom-crawler", + "version": "v7.2.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "19cc7b08efe9ad1ab1b56e0948e8d02e15ed3ef7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/19cc7b08efe9ad1ab1b56e0948e8d02e15ed3ef7", + "reference": "19cc7b08efe9ad1ab1b56e0948e8d02e15ed3ef7", + "shasum": "" + }, + "require": { + "masterminds/html5": "^2.6", + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0" + }, + "require-dev": { + "symfony/css-selector": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v7.2.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-02-17T15:53:07+00:00" + }, { "name": "symfony/error-handler", "version": "v7.2.5", @@ -3880,6 +4082,179 @@ ], "time": "2024-12-30T19:00:17+00:00" }, + { + "name": "symfony/http-client", + "version": "v7.2.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "78981a2ffef6437ed92d4d7e2a86a82f256c6dc6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/78981a2ffef6437ed92d4d7e2a86a82f256c6dc6", + "reference": "78981a2ffef6437ed92d4d7e2a86a82f256c6dc6", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<2.5", + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.4" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", + "amphp/socket": "^1.1", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/rate-limiter": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v7.2.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-02-13T10:27:23+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.5.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "ee8d807ab20fcb51267fdace50fbe3494c31e645" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/ee8d807ab20fcb51267fdace50fbe3494c31e645", + "reference": "ee8d807ab20fcb51267fdace50fbe3494c31e645", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.5-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.5.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-07T08:49:48+00:00" + }, { "name": "symfony/http-foundation", "version": "v7.2.6", diff --git a/routes/console.php b/routes/console.php index 3c9adf1..f7b366e 100644 --- a/routes/console.php +++ b/routes/console.php @@ -3,6 +3,7 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; -Artisan::command('inspire', function () { - $this->comment(Inspiring::quote()); -})->purpose('Display an inspiring quote'); +Schedule::command('currency:fetch --today') + ->everySixHours() // + ->appendOutputTo(storage_path('logs/currency-fetch.log')); + \ No newline at end of file diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index 8364a84..0000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,19 +0,0 @@ -get('/'); - - $response->assertStatus(200); - } -} diff --git a/tests/Feature/Services/CurrencyServiceFeatureTest.php b/tests/Feature/Services/CurrencyServiceFeatureTest.php new file mode 100644 index 0000000..aef4026 --- /dev/null +++ b/tests/Feature/Services/CurrencyServiceFeatureTest.php @@ -0,0 +1,80 @@ +currencyService = new CurrencyService(); + } + + /** + * Test getting last twenty days of currency data + */ + public function testGetLastTwentyDays() + { + $result = $this->currencyService->getLastTwentyDays(); + + $this->assertIsArray($result); + $this->assertNotEmpty($result); + + $firstItem = $result[0]; + $this->assertArrayHasKey('currency', $firstItem); + $this->assertArrayHasKey('city', $firstItem); + $this->assertArrayHasKey('price_buy', $firstItem); + $this->assertArrayHasKey('price_sell', $firstItem); + $this->assertArrayHasKey('date', $firstItem); + $this->assertArrayHasKey('day', $firstItem); + + $this->assertContains('Sanaa', array_column($result, 'city')); + $this->assertContains('Aden', array_column($result, 'city')); + + $this->assertContains('USD', array_column($result, 'currency')); + $this->assertContains('SAR', array_column($result, 'currency')); + } + + /** + * Test getting today's currencies + */ + public function testGetTodayCurrencies() + { + $result = $this->currencyService->getTodayCurrencies(); + + // Assertions + $this->assertIsArray($result); + $this->assertNotEmpty($result); + + // Should only return today's data for Sanaa and Aden + // In our sample, there are 4 entries for today (2 cities x 2 currencies) + $todayDate = Carbon::today()->format('Y-m-d'); + $todayItems = array_filter($result, function ($item) use ($todayDate) { + return $item['date'] === $todayDate; + }); + + + // Check that we have both cities + $cities = array_column($todayItems, 'city'); + $this->assertContains('Sanaa', $cities); + $this->assertContains('Aden', $cities); + + // Check that we have both currencies + $currencies = array_column($todayItems, 'currency'); + $this->assertContains('USD', $currencies); + $this->assertContains('SAR', $currencies); + } +} diff --git a/tests/Unit/CurrencyServiceTest.php b/tests/Unit/CurrencyServiceTest.php new file mode 100644 index 0000000..4da2c0c --- /dev/null +++ b/tests/Unit/CurrencyServiceTest.php @@ -0,0 +1,205 @@ +mockHttpBrowser = Mockery::mock(HttpBrowser::class); + + // Create the service with mocked dependencies + $this->currencyService = Mockery::mock(CurrencyService::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + + // Configure the service to return our mock + $this->currencyService->shouldAllowMockingProtectedMethods(); + + $this->currencyService->shouldReceive('createHttpBrowser') + ->andReturn($this->mockHttpBrowser); + } + + public function testGetTodayCurrenciesFiltersForTodayAndCities() + { + // Sample data that includes today and past dates + $today = Carbon::today()->format('Y-m-d'); + $yesterday = Carbon::yesterday()->format('Y-m-d'); + + $testData = [ + [ + 'currency' => 'USD', + 'city' => 'Sanaa', + 'price_buy' => 535.0, + 'price_sell' => 537.0, + 'date' => $today, + 'day' => Carbon::today()->format('l') + ], + [ + 'currency' => 'SAR', + 'city' => 'Sanaa', + 'price_buy' => 139.8, + 'price_sell' => 140.2, + 'date' => $today, + 'day' => Carbon::today()->format('l') + ], + [ + 'currency' => 'USD', + 'city' => 'Aden', + 'price_buy' => 2541.0, + 'price_sell' => 2556.0, + 'date' => $today, + 'day' => Carbon::today()->format('l') + ], + [ + 'currency' => 'SAR', + 'city' => 'Aden', + 'price_buy' => 668.0, + 'price_sell' => 670.0, + 'date' => $today, + 'day' => Carbon::today()->format('l') + ], + [ + 'currency' => 'USD', + 'city' => 'Sanaa', + 'price_buy' => 534.0, + 'price_sell' => 536.0, + 'date' => $yesterday, + 'day' => Carbon::yesterday()->format('l') + ], + [ + 'currency' => 'USD', + 'city' => 'OtherCity', + 'price_buy' => 540.0, + 'price_sell' => 542.0, + 'date' => $today, + 'day' => Carbon::today()->format('l') + ] + ]; + + // Create a new instance for this test + $currencyService = Mockery::mock(CurrencyService::class)->makePartial(); + $currencyService->shouldReceive('getLastTwentyDays') + ->once() + ->andReturn($testData); + + // Test the method + $result = $currencyService->getTodayCurrencies(); + + // Should only include today's data for Sanaa and Aden + $this->assertCount(4, $result); + + // Check that only Sanaa and Aden cities are included + $cities = array_column($result, 'city'); + $this->assertContains('Sanaa', $cities); + $this->assertContains('Aden', $cities); + $this->assertNotContains('OtherCity', $cities); + + // Check that only today's data is included + $dates = array_unique(array_column($result, 'date')); + $this->assertCount(1, $dates); + $this->assertEquals($today, $dates[0]); + } + + public function testGetTodayCurrenciesHandlesExceptions() + { + // Create a subclass of CurrencyService for testing + $currencyService = new class extends CurrencyService { + // Override getLastTwentyDays to throw an exception + public function getLastTwentyDays(): array + { + throw new \Exception('Test exception'); + } + }; + + // Test the method - this will call the real getTodayCurrencies which calls our overridden getLastTwentyDays + $result = $currencyService->getTodayCurrencies(); + + // Should return an empty array on exception + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testGetTodayCurrenciesUsesRecentDataWhenTodayNotAvailable() + { + // Sample data that includes only past dates + $yesterday = Carbon::yesterday()->format('Y-m-d'); + $twoDaysAgo = Carbon::yesterday()->subDay()->format('Y-m-d'); + + $testData = [ + [ + 'currency' => 'USD', + 'city' => 'Sanaa', + 'price_buy' => 534.0, + 'price_sell' => 536.0, + 'date' => $yesterday, + 'day' => Carbon::yesterday()->format('l') + ], + [ + 'currency' => 'SAR', + 'city' => 'Sanaa', + 'price_buy' => 139.0, + 'price_sell' => 140.0, + 'date' => $yesterday, + 'day' => Carbon::yesterday()->format('l') + ], + [ + 'currency' => 'USD', + 'city' => 'Aden', + 'price_buy' => 2540.0, + 'price_sell' => 2555.0, + 'date' => $yesterday, + 'day' => Carbon::yesterday()->format('l') + ], + [ + 'currency' => 'USD', + 'city' => 'Sanaa', + 'price_buy' => 533.0, + 'price_sell' => 535.0, + 'date' => $twoDaysAgo, + 'day' => Carbon::yesterday()->subDay()->format('l') + ] + ]; + + // Create a new instance for this test + $currencyService = Mockery::mock(CurrencyService::class)->makePartial(); + $currencyService->shouldReceive('getLastTwentyDays') + ->once() + ->andReturn($testData); + + // Test the method + $result = $currencyService->getTodayCurrencies(); + + // Should include yesterday's data for Sanaa and Aden + $this->assertCount(3, $result); + + // Check that only Sanaa and Aden cities are included + $cities = array_column($result, 'city'); + $this->assertContains('Sanaa', $cities); + $this->assertContains('Aden', $cities); + + // Check that only yesterday's data is included (most recent) + $dates = array_unique(array_column($result, 'date')); + $this->assertCount(1, $dates); + $this->assertEquals($yesterday, $dates[0]); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index 5773b0c..0000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,16 +0,0 @@ -assertTrue(true); - } -} diff --git a/tests/Unit/FetchCurrencyCommandTest.php b/tests/Unit/FetchCurrencyCommandTest.php new file mode 100644 index 0000000..c09c8cc --- /dev/null +++ b/tests/Unit/FetchCurrencyCommandTest.php @@ -0,0 +1,140 @@ +currencyService = Mockery::mock(CurrencyService::class); + $this->app->instance(CurrencyService::class, $this->currencyService); + + // Create the command + $this->command = new FetchCurrencyCommand(); + } + + public function testHandleWithTodayOption() + { + // Sample data for today's currencies + $todayCurrencies = [ + [ + 'currency' => 'USD', + 'city' => 'Sanaa', + 'price_buy' => 535.0, + 'price_sell' => 537.0, + 'date' => '2025-05-19', + 'day' => 'Monday' + ], + [ + 'currency' => 'SAR', + 'city' => 'Sanaa', + 'price_buy' => 139.8, + 'price_sell' => 140.2, + 'date' => '2025-05-19', + 'day' => 'Monday' + ] + ]; + + // Configure the mock to return our sample data + $this->currencyService->shouldReceive('getTodayCurrencies') + ->once() + ->andReturn($todayCurrencies); + + // We don't expect getLastTwentyDays to be called + $this->currencyService->shouldReceive('getLastTwentyDays') + ->never(); + + // Create a mock for the command to capture output + $this->artisan('currency:fetch', ['--today' => true]) + ->expectsOutput('Fetching currency data...') + ->expectsOutput('Today\'s Currency Data:') + ->expectsOutput('Currency data fetched successfully!') + ->assertExitCode(0); + } + + public function testHandleWithoutTodayOption() + { + // Sample data for historical currencies + $historicalCurrencies = [ + [ + 'currency' => 'USD', + 'city' => 'Sanaa', + 'price_buy' => 535.0, + 'price_sell' => 537.0, + 'date' => '2025-05-19', + 'day' => 'Monday' + ], + [ + 'currency' => 'USD', + 'city' => 'Sanaa', + 'price_buy' => 534.0, + 'price_sell' => 536.0, + 'date' => '2025-05-18', + 'day' => 'Sunday' + ] + ]; + + // Configure the mock to return our sample data + $this->currencyService->shouldReceive('getLastTwentyDays') + ->once() + ->andReturn($historicalCurrencies); + + // We don't expect getTodayCurrencies to be called + $this->currencyService->shouldReceive('getTodayCurrencies') + ->never(); + + // Create a mock for the command to capture output + $this->artisan('currency:fetch') + ->expectsOutput('Fetching currency data...') + ->expectsOutput('Last 20 Days Currency Data:') + ->expectsOutput('Currency data fetched successfully!') + ->assertExitCode(0); + } + + public function testHandleWithEmptyData() + { + // Configure the mock to return empty data + $this->currencyService->shouldReceive('getLastTwentyDays') + ->once() + ->andReturn([]); + + // Create a mock for the command to capture output + $this->artisan('currency:fetch') + ->expectsOutput('Fetching currency data...') + ->expectsOutput('No historical currency data available.') + ->expectsOutput('Currency data fetched successfully!') + ->assertExitCode(0); + } + + public function testHandleWithException() + { + // Configure the mock to throw an exception + $this->currencyService->shouldReceive('getLastTwentyDays') + ->once() + ->andThrow(new \Exception('Test exception')); + + // Create a mock for the command to capture output + $this->artisan('currency:fetch') + ->expectsOutput('Fetching currency data...') + ->expectsOutput('Failed to fetch currency data: Test exception') + ->assertExitCode(1); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +}