feat: implement currency exchange rate scraping service and command
هذا الالتزام موجود في:
102
app/Console/Commands/FetchCurrencyCommand.php
Normal file
102
app/Console/Commands/FetchCurrencyCommand.php
Normal file
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
177
app/Services/CurrencyService.php
Normal file
177
app/Services/CurrencyService.php
Normal file
@@ -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
مولّد
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);
|
||||
}
|
||||
}
|
||||
80
tests/Feature/Services/CurrencyServiceFeatureTest.php
Normal file
80
tests/Feature/Services/CurrencyServiceFeatureTest.php
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
205
tests/Unit/CurrencyServiceTest.php
Normal file
205
tests/Unit/CurrencyServiceTest.php
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
140
tests/Unit/FetchCurrencyCommandTest.php
Normal file
140
tests/Unit/FetchCurrencyCommandTest.php
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
المرجع في مشكلة جديدة
حظر مستخدم