feat: implement currency exchange rate scraping service and command

هذا الالتزام موجود في:
2025-05-24 01:37:27 +03:00
الأصل 76c2ab12c8
التزام 72b4eff3ee
10 ملفات معدلة مع 1087 إضافات و40 حذوفات

عرض الملف

@@ -0,0 +1,102 @@
<?php
namespace App\Console\Commands;
use App\Services\CurrencyService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class FetchCurrencyCommand extends Command
{
protected $signature = 'currency:fetch {--today : Fetch only today\'s data}';
protected $description = 'Fetch currency exchange rates from boqash.com';
/**
* Execute the console command.
*
* @return int
*/
public function handle(CurrencyService $currencyService) : int
{
$this->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)
);
}
}

عرض الملف

@@ -0,0 +1,177 @@
<?php
namespace App\Services;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
use Symfony\Component\BrowserKit\HttpBrowser;
use Symfony\Component\DomCrawler\Crawler;
class CurrencyService
{
/**
* The URL to scrape currency data from
*/
protected string $url = 'https://boqash.com/price-currency/';
/**
* Cities to filter by
*/
protected array $cities = ['Sanaa', 'Aden'];
/**
* Cache TTL in minutes
*
* 5 minutes less than 6 hours to avoid cache conflicts with schedule command every six hours
*/
protected int $cacheTtl = 350;
public function getLastTwentyDays(): array
{
try {
$client = new HttpBrowser();
$crawler = $client->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);
}
}

عرض الملف

@@ -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",

377
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",

عرض الملف

@@ -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'));

عرض الملف

@@ -1,19 +0,0 @@
<?php
namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_the_application_returns_a_successful_response(): void
{
$response = $this->get('/');
$response->assertStatus(200);
}
}

عرض الملف

@@ -0,0 +1,80 @@
<?php
namespace Tests\Feature\Services;
use App\Services\CurrencyService;
use Carbon\Carbon;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Symfony\Component\DomCrawler\Crawler;
use Tests\TestCase;
/**
* @group real test - requires internet connection and maybe slow
*/
class CurrencyServiceFeatureTest extends TestCase
{
protected $currencyService;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}

عرض الملف

@@ -0,0 +1,205 @@
<?php
namespace Tests\Unit;
use App\Services\CurrencyService;
use Illuminate\Support\Carbon;
use Mockery;
use Symfony\Component\BrowserKit\HttpBrowser;
use Symfony\Component\DomCrawler\Crawler;
use Tests\TestCase;
class CurrencyServiceTest extends TestCase
{
protected $currencyService;
protected $mockHttpBrowser;
protected function setUp(): void
{
parent::setUp();
// Create a mock for HttpBrowser
$this->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();
}
}

عرض الملف

@@ -1,16 +0,0 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_that_true_is_true(): void
{
$this->assertTrue(true);
}
}

عرض الملف

@@ -0,0 +1,140 @@
<?php
namespace Tests\Unit;
use Tests\TestCase;
use App\Console\Commands\FetchCurrencyCommand;
use App\Services\CurrencyService;
use Mockery;
class FetchCurrencyCommandTest extends TestCase
{
protected $command;
protected $currencyService;
protected function setUp(): void
{
parent::setUp();
// Create a mock for the CurrencyService
$this->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();
}
}