diff --git a/.env.local-example b/.env.local-example index 789b5f1663ac09c686c9496474a848dfbfab53a1..004ebc7adb39a9800ad2126fb1989674b8ce6005 100644 --- a/.env.local-example +++ b/.env.local-example @@ -82,4 +82,8 @@ CDN_FILESYSTEM_DISK=bunnycdn AI_PROVIDER=openai OPENAI_URL=https://api.openai.com/v1/chat/completions OPENAI_API_KEY= -OPENAI_MODEL=gpt-4o-mini \ No newline at end of file +OPENAI_MODEL=gpt-4o-mini + +IP_ADDRESS_RESOLVER_URL=http://ip-api.com/batch +IP_ADDRESS_RESOLVER_CALL_LIMIT_PER_MINUTE=15 +IP_ADDRESS_RESOLVER_MAX_IP_ADDRESSES_PER_CALL=100 \ No newline at end of file diff --git a/.env.production b/.env.production index 4306866dbd91a2ba10c3c8c77423bafcc8dbce5b..550e0c24aa911625bc3ced77fca93a8091b0bf45 100644 --- a/.env.production +++ b/.env.production @@ -82,4 +82,8 @@ CDN_FILESYSTEM_DISK=bunnycdn AI_PROVIDER=openai OPENAI_URL=https://api.openai.com/v1/chat/completions OPENAI_API_KEY= -OPENAI_MODEL=gpt-4o-mini \ No newline at end of file +OPENAI_MODEL=gpt-4o-mini + +IP_ADDRESS_RESOLVER_URL=http://ip-api.com/batch +IP_ADDRESS_RESOLVER_CALL_LIMIT_PER_MINUTE=15 +IP_ADDRESS_RESOLVER_MAX_IP_ADDRESSES_PER_CALL=100 \ No newline at end of file diff --git a/.env.testing b/.env.testing index 27f3f5951fe626efb7e765dee1da5748abae4a5d..28fca8c4a6dffc4d924910a81327e8849727f7a6 100644 --- a/.env.testing +++ b/.env.testing @@ -78,4 +78,8 @@ BUNNYCDN_REGION=de AI_PROVIDER=openai OPENAI_URL=https://api.test-provider.com/v1/chat/completions OPENAI_API_KEY=fake-api-key -OPENAI_MODEL=gpt-4o-mini \ No newline at end of file +OPENAI_MODEL=gpt-4o-mini + +IP_ADDRESS_RESOLVER_URL=http://api.test-provider.com/batch +IP_ADDRESS_RESOLVER_CALL_LIMIT_PER_MINUTE=15 +IP_ADDRESS_RESOLVER_MAX_IP_ADDRESSES_PER_CALL=100 \ No newline at end of file diff --git a/app/Console/Commands/ProcessIpAdressesCommand.php b/app/Console/Commands/ProcessIpAdressesCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..c9f8b9e07e5ca2c41dfaa11a1386b3115a4f2d3e --- /dev/null +++ b/app/Console/Commands/ProcessIpAdressesCommand.php @@ -0,0 +1,31 @@ +<?php + +namespace App\Console\Commands; + +use App\Jobs\ProcessIpAddressesJob; +use Illuminate\Console\Command; +use SlProjects\LaravelRequestLogger\app\Models\IpAddress; + +class ProcessIpAdressesCommand extends Command +{ + protected $signature = 'process:ip-adresses'; + + protected $description = 'Process ip adresses to resolve their location'; + + public function handle(): void + { + $ipAddresses = IpAddress::leftJoin('ip_address_metadata', 'ip_addresses.id', '=', 'ip_address_metadata.ip_address_id') + ->whereNull('ip_address_metadata.id') + ->select('ip_addresses.id', 'ip_addresses.ip') + ->get(); + + if ($ipAddresses->isEmpty()) { + $this->info('No ip adresses to process'); + + return; + } + + $this->info("Processing {$ipAddresses->count()} ip adresses"); + ProcessIpAddressesJob::dispatch($ipAddresses); + } +} diff --git a/app/Http/Controllers/Admin/HomeController.php b/app/Http/Controllers/Admin/HomeController.php index 2af97676ffd9615bf9148b3d58a2d93806163d5f..a8afe81bf11a48259269882c97db4be82ab87ef1 100644 --- a/app/Http/Controllers/Admin/HomeController.php +++ b/app/Http/Controllers/Admin/HomeController.php @@ -22,16 +22,16 @@ public function index(): View } } - $visits = LoggedRequest::selectRaw(' - logged_requests.url_id, - logged_requests.ip_address_id, - logged_requests.country_code, - logged_requests.created_at, - urls.url - ') + $visits = LoggedRequest::select([ + 'logged_requests.url_id', + 'logged_requests.ip_address_id', + 'ip_address_metadata.country_code', + 'logged_requests.created_at', + 'urls.url']) ->distinct('logged_requests.url_id', 'logged_requests.ip_address_id') ->join('urls', 'logged_requests.url_id', '=', 'urls.id') ->join('user_agent_metadata', 'logged_requests.user_agent_id', '=', 'user_agent_metadata.user_agent_id') + ->join('ip_address_metadata', 'logged_requests.ip_address_id', '=', 'ip_address_metadata.ip_address_id') ->whereLike('urls.url', config('app.url').'%') ->whereNotIn('urls.url', $individualExcludedRoutes) ->where('user_agent_metadata.is_bot', false) diff --git a/app/Jobs/ProcessIpAddressesJob.php b/app/Jobs/ProcessIpAddressesJob.php new file mode 100644 index 0000000000000000000000000000000000000000..d02f8cd33ecd23af984eb247bfdb10128ff18392 --- /dev/null +++ b/app/Jobs/ProcessIpAddressesJob.php @@ -0,0 +1,51 @@ +<?php + +namespace App\Jobs; + +use App\Models\IpAddressMetadata; +use App\Services\IpAddressMetadataResolverService; +use Exception; +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; + +class ProcessIpAddressesJob implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + public function __construct(private readonly Collection $ipAddresses) {} + + public function handle(IpAddressMetadataResolverService $ipAddressMetadataResolver): void + { + try { + $metadataObjects = $ipAddressMetadataResolver->resolve($this->ipAddresses); + } catch (Exception $exception) { + report($exception); + $this->fail($exception); + + return; + } + + foreach ($metadataObjects as $metadata) { + if ($metadata['status'] === 'fail') { + Log::warning('Failed to resolve IP address metadata.', [ + 'query' => $metadata['query'], + 'message' => $metadata['message'], + ]); + + continue; + } + + IpAddressMetadata::create([ + 'ip_address_id' => $this->ipAddresses->where('ip', $metadata['query'])->first()->id, + 'country_code' => $metadata['countryCode'], + 'lat' => $metadata['lat'], + 'lon' => $metadata['lon'], + ]); + } + } +} diff --git a/app/Models/IpAddressMetadata.php b/app/Models/IpAddressMetadata.php new file mode 100644 index 0000000000000000000000000000000000000000..6c0f61999c3b791d570c8b0ec353d43614b7c725 --- /dev/null +++ b/app/Models/IpAddressMetadata.php @@ -0,0 +1,29 @@ +<?php + +namespace App\Models; + +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use SlProjects\LaravelRequestLogger\app\Models\IpAddress; + +class IpAddressMetadata extends Model +{ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'ip_address_id', + 'country_code', + 'lat', + 'lon', + ]; + + const COUNTRY_CODES = ['AA', 'AB', 'AC', 'AD', 'AE', 'AF', 'AG', 'AH', 'AI', 'AJ', 'AK', 'AL', 'AM', 'AN', 'AO', 'AP', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AV', 'AW', 'AX', 'AY', 'AZ', 'BA', 'BB', 'BC', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BK', 'BL', 'BM', 'BN', 'BO', 'BP', 'BQ', 'BR', 'BS', 'BT', 'BU', 'BV', 'BW', 'BX', 'BY', 'BZ', 'CA', 'CB', 'CC', 'CD', 'CE', 'CF', 'CG', 'CH', 'CI', 'CJ', 'CK', 'CL', 'CM', 'CN', 'CO', 'CP', 'CQ', 'CR', 'CS', 'CT', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DA', 'DB', 'DC', 'DD', 'DE', 'DF', 'DG', 'DH', 'DI', 'DJ', 'DK', 'DL', 'DM', 'DN', 'DO', 'DP', 'DQ', 'DR', 'DS', 'DT', 'DU', 'DV', 'DW', 'DX', 'DY', 'DZ', 'EA', 'EB', 'EC', 'ED', 'EE', 'EF', 'EG', 'EH', 'EI', 'EJ', 'EK', 'EL', 'EM', 'EN', 'EO', 'EP', 'EQ', 'ER', 'ES', 'ET', 'EU', 'EV', 'EW', 'EX', 'EY', 'EZ', 'FA', 'FB', 'FC', 'FD', 'FE', 'FF', 'FG', 'FH', 'FI', 'FJ', 'FK', 'FL', 'FM', 'FN', 'FO', 'FP', 'FQ', 'FR', 'FS', 'FT', 'FU', 'FV', 'FW', 'FX', 'FY', 'FZ', 'GA', 'GB', 'GC', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GJ', 'GK', 'GL', 'GM', 'GN', 'GO', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GV', 'GW', 'GX', 'GY', 'GZ', 'HA', 'HB', 'HC', 'HD', 'HE', 'HF', 'HG', 'HH', 'HI', 'HJ', 'HK', 'HL', 'HM', 'HN', 'HO', 'HP', 'HQ', 'HR', 'HS', 'HT', 'HU', 'HV', 'HW', 'HX', 'HY', 'HZ', 'IA', 'IB', 'IC', 'ID', 'IE', 'IF', 'IG', 'IH', 'II', 'IJ', 'IK', 'IL', 'IM', 'IN', 'IO', 'IP', 'IQ', 'IR', 'IS', 'IT', 'IU', 'IV', 'IW', 'IX', 'IY', 'IZ', 'JA', 'JB', 'JC', 'JD', 'JE', 'JF', 'JG', 'JH', 'JI', 'JJ', 'JK', 'JL', 'JM', 'JN', 'JO', 'JP', 'JQ', 'JR', 'JS', 'JT', 'JU', 'JV', 'JW', 'JX', 'JY', 'JZ', 'KA', 'KB', 'KC', 'KD', 'KE', 'KF', 'KG', 'KH', 'KI', 'KJ', 'KK', 'KL', 'KM', 'KN', 'KO', 'KP', 'KQ', 'KR', 'KS', 'KT', 'KU', 'KV', 'KW', 'KX', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LD', 'LE', 'LF', 'LG', 'LH', 'LI', 'LJ', 'LK', 'LL', 'LM', 'LN', 'LO', 'LP', 'LQ', 'LR', 'LS', 'LT', 'LU', 'LV', 'LW', 'LX', 'LY', 'LZ', 'MA', 'MB', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MI', 'MJ', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NA', 'NB', 'NC', 'ND', 'NE', 'NF', 'NG', 'NH', 'NI', 'NJ', 'NK', 'NL', 'NM', 'NN', 'NO', 'NP', 'NQ', 'NR', 'NS', 'NT', 'NU', 'NV', 'NW', 'NX', 'NY', 'NZ', 'OA', 'OB', 'OC', 'OD', 'OE', 'OF', 'OG', 'OH', 'OI', 'OJ', 'OK', 'OL', 'OM', 'ON', 'OO', 'OP', 'OQ', 'OR', 'OS', 'OT', 'OU', 'OV', 'OW', 'OX', 'OY', 'OZ', 'PA', 'PB', 'PC', 'PD', 'PE', 'PF', 'PG', 'PH', 'PI', 'PJ', 'PK', 'PL', 'PM', 'PN', 'PO', 'PP', 'PQ', 'PR', 'PS', 'PT', 'PU', 'PV', 'PW', 'PX', 'PY', 'PZ', 'QA', 'QB', 'QC', 'QD', 'QE', 'QF', 'QG', 'QH', 'QI', 'QJ', 'QK', 'QL', 'QM', 'QN', 'QO', 'QP', 'QQ', 'QR', 'QS', 'QT', 'QU', 'QV', 'QW', 'QX', 'QY', 'QZ', 'RA', 'RB', 'RC', 'RD', 'RE', 'RF', 'RG', 'RH', 'RI', 'RJ', 'RK', 'RL', 'RM', 'RN', 'RO', 'RP', 'RQ', 'RR', 'RS', 'RT', 'RU', 'RV', 'RW', 'RX', 'RY', 'RZ', 'SA', 'SB', 'SC', 'SD', 'SE', 'SF', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SP', 'SQ', 'SR', 'SS', 'ST', 'SU', 'SV', 'SW', 'SX', 'SY', 'SZ', 'TA', 'TB', 'TC', 'TD', 'TE', 'TF', 'TG', 'TH', 'TI', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TP', 'TQ', 'TR', 'TS', 'TT', 'TU', 'TV', 'TW', 'TX', 'TY', 'TZ', 'UA', 'UB', 'UC', 'UD', 'UE', 'UF', 'UG', 'UH', 'UI', 'UJ', 'UK', 'UL', 'UM', 'UN', 'UO', 'UP', 'UQ', 'UR', 'US', 'UT', 'UU', 'UV', 'UW', 'UX', 'UY', 'UZ', 'VA', 'VB', 'VC', 'VD', 'VE', 'VF', 'VG', 'VH', 'VI', 'VJ', 'VK', 'VL', 'VM', 'VN', 'VO', 'VP', 'VQ', 'VR', 'VS', 'VT', 'VU', 'VV', 'VW', 'VX', 'VY', 'VZ', 'WA', 'WB', 'WC', 'WD', 'WE', 'WF', 'WG', 'WH', 'WI', 'WJ', 'WK', 'WL', 'WM', 'WN', 'WO', 'WP', 'WQ', 'WR', 'WS', 'WT', 'WU', 'WV', 'WW', 'WX', 'WY', 'WZ', 'XA', 'XB', 'XC', 'XD', 'XE', 'XF', 'XG', 'XH', 'XI', 'XJ', 'XK', 'XL', 'XM', 'XN', 'XO', 'XP', 'XQ', 'XR', 'XS', 'XT', 'XU', 'XV', 'XW', 'XX', 'XY', 'XZ', 'YA', 'YB', 'YC', 'YD', 'YE', 'YF', 'YG', 'YH', 'YI', 'YJ', 'YK', 'YL', 'YM', 'YN', 'YO', 'YP', 'YQ', 'YR', 'YS', 'YT', 'YU', 'YV', 'YW', 'YX', 'YY', 'YZ', 'ZA', 'ZB', 'ZC', 'ZD', 'ZE', 'ZF', 'ZG', 'ZH', 'ZI', 'ZJ', 'ZK', 'ZL', 'ZM', 'ZN', 'ZO', 'ZP', 'ZQ', 'ZR', 'ZS', 'ZT', 'ZU', 'ZV', 'ZW', 'ZX', 'ZY', 'ZZ']; + + public function ipAddress(): BelongsTo + { + return $this->belongsTo(IpAddress::class); + } +} diff --git a/app/Services/IpAddressMetadataResolverService.php b/app/Services/IpAddressMetadataResolverService.php new file mode 100644 index 0000000000000000000000000000000000000000..13f4eaec3cc317a5d6536bbe8ace6df74f42457a --- /dev/null +++ b/app/Services/IpAddressMetadataResolverService.php @@ -0,0 +1,69 @@ +<?php + +namespace App\Services; + +use Exception; +use Illuminate\Http\Client\ConnectionException; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; +use SlProjects\LaravelRequestLogger\app\Models\IpAddress; + +class IpAddressMetadataResolverService +{ + /** + * Resolve metadata for the given IP addresses. + * This method has a fail tolerance. The only exception that is thrown is when the API server returns a 422 status code. + * Server errors are logged and the method returns without throwing an exception. + * + * @param Collection $ipAddresses The IP addresses to resolve metadata for. Instances of IpAddress. + * @return array{array{status: 'success'|'fail', message?: string, countryCode?: string, lat?: float, lon?: float, query: string}} The resolved metadata for each IP address. + * + * @throws ConnectionException + */ + public static function resolve(Collection $ipAddresses): array + { + $url = config('ip-address-resolver.url').'?fields=status,message,countryCode,lat,lon,query'; + $maxIpPerCall = config('ip-address-resolver.max_ip_addresses_per_call'); + $maxCallsPerMinute = config('ip-address-resolver.call_limit_per_minute'); + $currentCallsCount = Cache::get('ip-address-resolver.calls_count', 0); + + if ($currentCallsCount >= $maxCallsPerMinute) { + Log::info('Max calls per minute reached. Skipping metadata resolution.'); + + return []; + } + + Cache::increment('ip-address-resolver.calls_count', 1, 60); + + if ($ipAddresses->count() > $maxIpPerCall) { + $ipAddresses = $ipAddresses->take($maxIpPerCall); + } + + $response = Http::post($url, $ipAddresses->pluck('ip')->toArray()); + + if ($response->failed()) { + $returnedError = [ + 'status' => $response->status(), + 'message' => $response->body(), + ]; + + if ($response->unprocessableContent()) { + $apiResponse = $response->json(); + Log::error('The API rejected the request with a 422 unprocessable entity status code. ', $returnedError); + throw new Exception('The API rejected the request with a 422 unprocessable entity status code. '.$apiResponse['message']); + } + if ($response->serverError()) { + Log::info('The API server encountered an error while processing the request. Skipping metadata resolution.', $returnedError); + + return []; + } + Log::error('The API server returned an unexpected status code. Skipping metadata resolution.', $returnedError); + + return []; + } + + return $response->json(); + } +} diff --git a/composer.lock b/composer.lock index 68990954135a6bfa33ca897c4252d7de6b967f14..31c03844cc31ebc1a50c1657014fe21bba8ec132 100644 --- a/composer.lock +++ b/composer.lock @@ -1680,16 +1680,16 @@ }, { "name": "laravel/framework", - "version": "v11.43.0", + "version": "v11.43.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "70760d976486310b11d8e487e873077db069e77a" + "reference": "053f26afb699c845945e7380b407dd019a0a2c74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/70760d976486310b11d8e487e873077db069e77a", - "reference": "70760d976486310b11d8e487e873077db069e77a", + "url": "https://api.github.com/repos/laravel/framework/zipball/053f26afb699c845945e7380b407dd019a0a2c74", + "reference": "053f26afb699c845945e7380b407dd019a0a2c74", "shasum": "" }, "require": { @@ -1891,7 +1891,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-02-18T15:37:56+00:00" + "time": "2025-02-19T16:06:03+00:00" }, { "name": "laravel/horizon", @@ -4527,16 +4527,16 @@ }, { "name": "sl-projects/laravel-request-logger", - "version": "v1.0.5", + "version": "v1.0.6", "source": { "type": "git", "url": "https://github.com/SofianeLasri/laravel-request-logger.git", - "reference": "d39cbc1f68a58e71d4df66b42196bc8fd51c315c" + "reference": "e42196f70e1c3b7b1ad2ea9a202b6d1e8c5a585f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SofianeLasri/laravel-request-logger/zipball/d39cbc1f68a58e71d4df66b42196bc8fd51c315c", - "reference": "d39cbc1f68a58e71d4df66b42196bc8fd51c315c", + "url": "https://api.github.com/repos/SofianeLasri/laravel-request-logger/zipball/e42196f70e1c3b7b1ad2ea9a202b6d1e8c5a585f", + "reference": "e42196f70e1c3b7b1ad2ea9a202b6d1e8c5a585f", "shasum": "" }, "require": { @@ -4557,7 +4557,8 @@ }, "autoload": { "psr-4": { - "SlProjects\\LaravelRequestLogger\\": "src/" + "SlProjects\\LaravelRequestLogger\\": "src/", + "SlProjects\\LaravelRequestLogger\\Database\\Factories\\": "src/database/factories/" } }, "notification-url": "https://packagist.org/downloads/", @@ -4575,9 +4576,9 @@ "description": "A Laravel package to log all incoming HTTP requests", "support": { "issues": "https://github.com/SofianeLasri/laravel-request-logger/issues", - "source": "https://github.com/SofianeLasri/laravel-request-logger/tree/v1.0.5" + "source": "https://github.com/SofianeLasri/laravel-request-logger/tree/v1.0.6" }, - "time": "2025-02-18T20:14:09+00:00" + "time": "2025-02-19T16:59:10+00:00" }, { "name": "spatie/commonmark-shiki-highlighter", @@ -8852,16 +8853,16 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.5", + "version": "2.1.6", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "451b17f9665481ee502adc39be987cb71067ece2" + "reference": "6eaec7c6c9e90dcfe46ad1e1ffa5171e2dab641c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/451b17f9665481ee502adc39be987cb71067ece2", - "reference": "451b17f9665481ee502adc39be987cb71067ece2", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/6eaec7c6c9e90dcfe46ad1e1ffa5171e2dab641c", + "reference": "6eaec7c6c9e90dcfe46ad1e1ffa5171e2dab641c", "shasum": "" }, "require": { @@ -8906,7 +8907,7 @@ "type": "github" } ], - "time": "2025-02-13T12:49:56+00:00" + "time": "2025-02-19T15:46:42+00:00" }, { "name": "phpunit/php-code-coverage", @@ -10435,7 +10436,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -10444,6 +10445,6 @@ "ext-pdo": "*", "ext-zip": "*" }, - "platform-dev": [], - "plugin-api-version": "2.3.0" + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/config/ip-address-resolver.php b/config/ip-address-resolver.php new file mode 100644 index 0000000000000000000000000000000000000000..7dc71a84fda14b4dd12a32ae5f08c7452edd2b95 --- /dev/null +++ b/config/ip-address-resolver.php @@ -0,0 +1,7 @@ +<?php + +return [ + 'url' => env('IP_ADDRESS_RESOLVER_URL', 'http://ip-api.com/batch'), + 'call_limit_per_minute' => env('IP_ADDRESS_RESOLVER_CALL_LIMIT_PER_MINUTE', 15), + 'max_ip_addresses_per_call' => env('IP_ADDRESS_RESOLVER_MAX_IP_ADDRESSES_PER_CALL', 100), +]; diff --git a/database/factories/IpAddressMetadataFactory.php b/database/factories/IpAddressMetadataFactory.php new file mode 100644 index 0000000000000000000000000000000000000000..5581b8193b0a4a4f0f6a2edf952e61f5bcf10303 --- /dev/null +++ b/database/factories/IpAddressMetadataFactory.php @@ -0,0 +1,23 @@ +<?php + +namespace Database\Factories; + +use App\Models\IpAddressMetadata; +use Illuminate\Database\Eloquent\Factories\Factory; +use SlProjects\LaravelRequestLogger\app\Models\IpAddress; + +class IpAddressMetadataFactory extends Factory +{ + protected $model = IpAddressMetadata::class; + + public function definition(): array + { + return [ + 'country_code' => $this->faker->randomElement(IpAddressMetadata::COUNTRY_CODES), + 'lat' => $this->faker->latitude(), + 'lon' => $this->faker->longitude(), + + 'ip_address_id' => IpAddress::factory(), + ]; + } +} diff --git a/database/migrations/2025_02_19_141925_create_ip_address_metadata_table.php b/database/migrations/2025_02_19_141925_create_ip_address_metadata_table.php new file mode 100644 index 0000000000000000000000000000000000000000..387e34bb37c327ce10231111387850c065c3f420 --- /dev/null +++ b/database/migrations/2025_02_19_141925_create_ip_address_metadata_table.php @@ -0,0 +1,24 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('ip_address_metadata', function (Blueprint $table) { + $table->id(); + $table->foreignId('ip_address_id')->constrained('ip_addresses'); + $table->enum('country_code', ['AA', 'AB', 'AC', 'AD', 'AE', 'AF', 'AG', 'AH', 'AI', 'AJ', 'AK', 'AL', 'AM', 'AN', 'AO', 'AP', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AV', 'AW', 'AX', 'AY', 'AZ', 'BA', 'BB', 'BC', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BK', 'BL', 'BM', 'BN', 'BO', 'BP', 'BQ', 'BR', 'BS', 'BT', 'BU', 'BV', 'BW', 'BX', 'BY', 'BZ', 'CA', 'CB', 'CC', 'CD', 'CE', 'CF', 'CG', 'CH', 'CI', 'CJ', 'CK', 'CL', 'CM', 'CN', 'CO', 'CP', 'CQ', 'CR', 'CS', 'CT', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DA', 'DB', 'DC', 'DD', 'DE', 'DF', 'DG', 'DH', 'DI', 'DJ', 'DK', 'DL', 'DM', 'DN', 'DO', 'DP', 'DQ', 'DR', 'DS', 'DT', 'DU', 'DV', 'DW', 'DX', 'DY', 'DZ', 'EA', 'EB', 'EC', 'ED', 'EE', 'EF', 'EG', 'EH', 'EI', 'EJ', 'EK', 'EL', 'EM', 'EN', 'EO', 'EP', 'EQ', 'ER', 'ES', 'ET', 'EU', 'EV', 'EW', 'EX', 'EY', 'EZ', 'FA', 'FB', 'FC', 'FD', 'FE', 'FF', 'FG', 'FH', 'FI', 'FJ', 'FK', 'FL', 'FM', 'FN', 'FO', 'FP', 'FQ', 'FR', 'FS', 'FT', 'FU', 'FV', 'FW', 'FX', 'FY', 'FZ', 'GA', 'GB', 'GC', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GJ', 'GK', 'GL', 'GM', 'GN', 'GO', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GV', 'GW', 'GX', 'GY', 'GZ', 'HA', 'HB', 'HC', 'HD', 'HE', 'HF', 'HG', 'HH', 'HI', 'HJ', 'HK', 'HL', 'HM', 'HN', 'HO', 'HP', 'HQ', 'HR', 'HS', 'HT', 'HU', 'HV', 'HW', 'HX', 'HY', 'HZ', 'IA', 'IB', 'IC', 'ID', 'IE', 'IF', 'IG', 'IH', 'II', 'IJ', 'IK', 'IL', 'IM', 'IN', 'IO', 'IP', 'IQ', 'IR', 'IS', 'IT', 'IU', 'IV', 'IW', 'IX', 'IY', 'IZ', 'JA', 'JB', 'JC', 'JD', 'JE', 'JF', 'JG', 'JH', 'JI', 'JJ', 'JK', 'JL', 'JM', 'JN', 'JO', 'JP', 'JQ', 'JR', 'JS', 'JT', 'JU', 'JV', 'JW', 'JX', 'JY', 'JZ', 'KA', 'KB', 'KC', 'KD', 'KE', 'KF', 'KG', 'KH', 'KI', 'KJ', 'KK', 'KL', 'KM', 'KN', 'KO', 'KP', 'KQ', 'KR', 'KS', 'KT', 'KU', 'KV', 'KW', 'KX', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LD', 'LE', 'LF', 'LG', 'LH', 'LI', 'LJ', 'LK', 'LL', 'LM', 'LN', 'LO', 'LP', 'LQ', 'LR', 'LS', 'LT', 'LU', 'LV', 'LW', 'LX', 'LY', 'LZ', 'MA', 'MB', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MI', 'MJ', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NA', 'NB', 'NC', 'ND', 'NE', 'NF', 'NG', 'NH', 'NI', 'NJ', 'NK', 'NL', 'NM', 'NN', 'NO', 'NP', 'NQ', 'NR', 'NS', 'NT', 'NU', 'NV', 'NW', 'NX', 'NY', 'NZ', 'OA', 'OB', 'OC', 'OD', 'OE', 'OF', 'OG', 'OH', 'OI', 'OJ', 'OK', 'OL', 'OM', 'ON', 'OO', 'OP', 'OQ', 'OR', 'OS', 'OT', 'OU', 'OV', 'OW', 'OX', 'OY', 'OZ', 'PA', 'PB', 'PC', 'PD', 'PE', 'PF', 'PG', 'PH', 'PI', 'PJ', 'PK', 'PL', 'PM', 'PN', 'PO', 'PP', 'PQ', 'PR', 'PS', 'PT', 'PU', 'PV', 'PW', 'PX', 'PY', 'PZ', 'QA', 'QB', 'QC', 'QD', 'QE', 'QF', 'QG', 'QH', 'QI', 'QJ', 'QK', 'QL', 'QM', 'QN', 'QO', 'QP', 'QQ', 'QR', 'QS', 'QT', 'QU', 'QV', 'QW', 'QX', 'QY', 'QZ', 'RA', 'RB', 'RC', 'RD', 'RE', 'RF', 'RG', 'RH', 'RI', 'RJ', 'RK', 'RL', 'RM', 'RN', 'RO', 'RP', 'RQ', 'RR', 'RS', 'RT', 'RU', 'RV', 'RW', 'RX', 'RY', 'RZ', 'SA', 'SB', 'SC', 'SD', 'SE', 'SF', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SP', 'SQ', 'SR', 'SS', 'ST', 'SU', 'SV', 'SW', 'SX', 'SY', 'SZ', 'TA', 'TB', 'TC', 'TD', 'TE', 'TF', 'TG', 'TH', 'TI', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TP', 'TQ', 'TR', 'TS', 'TT', 'TU', 'TV', 'TW', 'TX', 'TY', 'TZ', 'UA', 'UB', 'UC', 'UD', 'UE', 'UF', 'UG', 'UH', 'UI', 'UJ', 'UK', 'UL', 'UM', 'UN', 'UO', 'UP', 'UQ', 'UR', 'US', 'UT', 'UU', 'UV', 'UW', 'UX', 'UY', 'UZ', 'VA', 'VB', 'VC', 'VD', 'VE', 'VF', 'VG', 'VH', 'VI', 'VJ', 'VK', 'VL', 'VM', 'VN', 'VO', 'VP', 'VQ', 'VR', 'VS', 'VT', 'VU', 'VV', 'VW', 'VX', 'VY', 'VZ', 'WA', 'WB', 'WC', 'WD', 'WE', 'WF', 'WG', 'WH', 'WI', 'WJ', 'WK', 'WL', 'WM', 'WN', 'WO', 'WP', 'WQ', 'WR', 'WS', 'WT', 'WU', 'WV', 'WW', 'WX', 'WY', 'WZ', 'XA', 'XB', 'XC', 'XD', 'XE', 'XF', 'XG', 'XH', 'XI', 'XJ', 'XK', 'XL', 'XM', 'XN', 'XO', 'XP', 'XQ', 'XR', 'XS', 'XT', 'XU', 'XV', 'XW', 'XX', 'XY', 'XZ', 'YA', 'YB', 'YC', 'YD', 'YE', 'YF', 'YG', 'YH', 'YI', 'YJ', 'YK', 'YL', 'YM', 'YN', 'YO', 'YP', 'YQ', 'YR', 'YS', 'YT', 'YU', 'YV', 'YW', 'YX', 'YY', 'YZ', 'ZA', 'ZB', 'ZC', 'ZD', 'ZE', 'ZF', 'ZG', 'ZH', 'ZI', 'ZJ', 'ZK', 'ZL', 'ZM', 'ZN', 'ZO', 'ZP', 'ZQ', 'ZR', 'ZS', 'ZT', 'ZU', 'ZV', 'ZW', 'ZX', 'ZY', 'ZZ']); + $table->float('lat')->nullable(); + $table->float('lon')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ip_address_metadata'); + } +}; diff --git a/routes/console.php b/routes/console.php index 0ef4bc96588dfaa08fe439c99b9ac6e9c86345ed..37195099b5989fd118ad261513cef434bea7a23b 100644 --- a/routes/console.php +++ b/routes/console.php @@ -9,6 +9,7 @@ Schedule::command('save:requests')->everyMinute()->sentryMonitor(); Schedule::command('process:user-agents')->hourly()->sentryMonitor(); +Schedule::command('process:ip-adresses')->everyFiveMinutes()->sentryMonitor(); Schedule::command('flush:unused-uploaded-pictures')->daily()->sentryMonitor(); Schedule::command('backup:clean --disable-notifications')->daily()->at('01:00'); diff --git a/tests/Feature/Console/Command/FlushUnusedUploadedPicturesCommandTest.php b/tests/Feature/Console/Commands/FlushUnusedUploadedPicturesCommandTest.php similarity index 86% rename from tests/Feature/Console/Command/FlushUnusedUploadedPicturesCommandTest.php rename to tests/Feature/Console/Commands/FlushUnusedUploadedPicturesCommandTest.php index 0d30022ee60d508e8dbaf02a80e4fb216bda598c..c362a64e7c121bee5b6d32811e0b114a02823342 100644 --- a/tests/Feature/Console/Command/FlushUnusedUploadedPicturesCommandTest.php +++ b/tests/Feature/Console/Commands/FlushUnusedUploadedPicturesCommandTest.php @@ -1,6 +1,6 @@ <?php -namespace Tests\Feature\Console\Command; +namespace Tests\Feature\Console\Commands; use App\Console\Commands\FlushUnusedUploadedPicturesCommand; use App\Models\UploadedPicture; @@ -9,7 +9,8 @@ use PHPUnit\Framework\Attributes\CoversClass; use Tests\TestCase; -#[CoversClass(FlushUnusedUploadedPicturesCommand::class)] class FlushUnusedUploadedPicturesCommandTest extends TestCase +#[CoversClass(FlushUnusedUploadedPicturesCommand::class)] +class FlushUnusedUploadedPicturesCommandTest extends TestCase { use RefreshDatabase; diff --git a/tests/Feature/Console/Command/OptimizeUploadedPicturesCommandTest.php b/tests/Feature/Console/Commands/OptimizeUploadedPicturesCommandTest.php similarity index 89% rename from tests/Feature/Console/Command/OptimizeUploadedPicturesCommandTest.php rename to tests/Feature/Console/Commands/OptimizeUploadedPicturesCommandTest.php index f9f09eefc283cf17693a50f4373d9818e0c10848..8863523704bf1d11e5b1cb36f88ba5cbdf012954 100644 --- a/tests/Feature/Console/Command/OptimizeUploadedPicturesCommandTest.php +++ b/tests/Feature/Console/Commands/OptimizeUploadedPicturesCommandTest.php @@ -1,6 +1,6 @@ <?php -namespace Tests\Feature\Console\Command; +namespace Tests\Feature\Console\Commands; use App\Console\Commands\OptimizeUploadedPicturesCommand; use App\Models\UploadedPicture; @@ -12,7 +12,8 @@ /** * Tests de la commande d'optimisation des images uploadées */ -#[CoversClass(OptimizeUploadedPicturesCommand::class)] class OptimizeUploadedPicturesCommandTest extends TestCase +#[CoversClass(OptimizeUploadedPicturesCommand::class)] +class OptimizeUploadedPicturesCommandTest extends TestCase { use RefreshDatabase; diff --git a/tests/Feature/Console/Commands/ProcessIpAdressesCommandTest.php b/tests/Feature/Console/Commands/ProcessIpAdressesCommandTest.php new file mode 100644 index 0000000000000000000000000000000000000000..87153e4c3ca3e11f8d6d9d78003014968bbadf11 --- /dev/null +++ b/tests/Feature/Console/Commands/ProcessIpAdressesCommandTest.php @@ -0,0 +1,28 @@ +<?php + +namespace Tests\Feature\Console\Commands; + +use App\Console\Commands\ProcessIpAdressesCommand; +use App\Jobs\ProcessIpAddressesJob; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Queue; +use PHPUnit\Framework\Attributes\CoversClass; +use SlProjects\LaravelRequestLogger\app\Models\IpAddress; +use Tests\TestCase; + +#[CoversClass(ProcessIpAdressesCommand::class)] +class ProcessIpAdressesCommandTest extends TestCase +{ + use RefreshDatabase; + + public function test_it_dispatches_job() + { + Queue::fake(); + IpAddress::factory()->count(10)->create(); + + Artisan::call('process:ip-adresses'); + + Queue::assertPushed(ProcessIpAddressesJob::class); + } +} diff --git a/tests/Feature/Console/Commands/ProcessUserAgentsCommandTest.php b/tests/Feature/Console/Commands/ProcessUserAgentsCommandTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c6390dc1a8ee4b9cd4845a767bf1514ef6dbb4ae --- /dev/null +++ b/tests/Feature/Console/Commands/ProcessUserAgentsCommandTest.php @@ -0,0 +1,27 @@ +<?php + +namespace Tests\Feature\Console\Commands; + +use App\Console\Commands\ProcessUserAgentsCommand; +use App\Jobs\ProcessUserAgentJob; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Queue; +use PHPUnit\Framework\Attributes\CoversClass; +use SlProjects\LaravelRequestLogger\app\Models\UserAgent; +use Tests\TestCase; + +#[CoversClass(ProcessUserAgentsCommand::class)] +class ProcessUserAgentsCommandTest extends TestCase +{ + use RefreshDatabase; + + public function test_it_dispatches_job() + { + Queue::fake(); + UserAgent::factory()->count(10)->create(); + + $this->artisan('process:user-agents'); + + Queue::assertPushed(ProcessUserAgentJob::class, 10); + } +} diff --git a/tests/Feature/Console/Command/UpdateImageDimensionsTest.php b/tests/Feature/Console/Commands/UpdateImageDimensionsCommandTest.php similarity index 92% rename from tests/Feature/Console/Command/UpdateImageDimensionsTest.php rename to tests/Feature/Console/Commands/UpdateImageDimensionsCommandTest.php index df50701450a1af1058cbe8f37272250ab2420b43..99b45783e993c20d5a5d156556b09330626dc42c 100644 --- a/tests/Feature/Console/Command/UpdateImageDimensionsTest.php +++ b/tests/Feature/Console/Commands/UpdateImageDimensionsCommandTest.php @@ -1,6 +1,6 @@ <?php -namespace Tests\Feature\Console\Command; +namespace Tests\Feature\Console\Commands; use App\Console\Commands\UpdateImageDimensionsCommand; use App\Models\UploadedPicture; @@ -15,7 +15,8 @@ /** * Tests de la commande de mise à jour des dimensions des images */ -#[CoversClass(UpdateImageDimensionsCommand::class)] class UpdateImageDimensionsTest extends TestCase +#[CoversClass(UpdateImageDimensionsCommand::class)] +class UpdateImageDimensionsCommandTest extends TestCase { use RefreshDatabase; diff --git a/tests/Feature/Jobs/ProcessIpAddressesJobTest.php b/tests/Feature/Jobs/ProcessIpAddressesJobTest.php new file mode 100644 index 0000000000000000000000000000000000000000..005713708d5713d40fe35eb84d601cc9b826f14c --- /dev/null +++ b/tests/Feature/Jobs/ProcessIpAddressesJobTest.php @@ -0,0 +1,59 @@ +<?php + +namespace Tests\Feature\Jobs; + +use App\Jobs\ProcessIpAddressesJob; +use App\Services\IpAddressMetadataResolverService; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; +use PHPUnit\Framework\Attributes\CoversClass; +use SlProjects\LaravelRequestLogger\app\Models\IpAddress; +use Tests\TestCase; + +#[CoversClass(ProcessIpAddressesJob::class)] +class ProcessIpAddressesJobTest extends TestCase +{ + use RefreshDatabase; + + public function test_processes_ip_addresses_successfully() + { + $ip1 = IpAddress::factory()->create(['ip' => '208.80.152.201']); + $ip2 = IpAddress::factory()->create(['ip' => '24.48.0.1']); + + Http::fake([ + '*' => Http::response([ + [ + 'status' => 'success', + 'countryCode' => 'US', + 'lat' => 37.7892, + 'lon' => -122.402, + 'query' => '208.80.152.201', + ], + [ + 'status' => 'success', + 'countryCode' => 'CA', + 'lat' => 45.6085, + 'lon' => -73.5493, + 'query' => '24.48.0.1', + ], + ]), + ]); + + $job = new ProcessIpAddressesJob(collect([$ip1, $ip2])); + $job->handle(app(IpAddressMetadataResolverService::class)); + + $this->assertDatabaseHas('ip_address_metadata', [ + 'ip_address_id' => $ip1->id, + 'country_code' => 'US', + 'lat' => 37.7892, + 'lon' => -122.402, + ]); + + $this->assertDatabaseHas('ip_address_metadata', [ + 'ip_address_id' => $ip2->id, + 'country_code' => 'CA', + 'lat' => 45.6085, + 'lon' => -73.5493, + ]); + } +} diff --git a/tests/Feature/Service/AiProviderServiceTest.php b/tests/Feature/Service/AiProviderServiceTest.php index 4cd5d62a5e19ca3c658c2a4fb3a9a3918fb44b02..1de43af3942ac979ba9f366f06300529db396eb9 100644 --- a/tests/Feature/Service/AiProviderServiceTest.php +++ b/tests/Feature/Service/AiProviderServiceTest.php @@ -60,7 +60,7 @@ public function test_prompt_with_pictures_sends_correct_request() { Storage::fake('public'); Http::fake([ - 'https://api.test-provider.com' => Http::response(json_encode($this->sampleResponse)), + 'https://api.test-provider.com' => Http::response($this->sampleResponse), ]); $mockTranscodingService = Mockery::mock(ImageTranscodingService::class); @@ -91,7 +91,7 @@ public function test_prompt_with_pictures_sends_correct_request() public function test_prompt_sends_correct_request() { Http::fake([ - 'https://api.test-provider.com' => Http::response(json_encode($this->sampleResponse)), + 'https://api.test-provider.com' => Http::response($this->sampleResponse), ]); Config::set('ai-provider.selected-provider', 'test-provider'); @@ -116,7 +116,7 @@ public function test_prompt_with_pictures_handles_transcoding_failure() { Storage::fake('public'); Http::fake([ - 'https://api.test-provider.com' => Http::response(json_encode($this->sampleResponse)), + 'https://api.test-provider.com' => Http::response($this->sampleResponse), ]); $mockTranscodingService = Mockery::mock(ImageTranscodingService::class); diff --git a/tests/Feature/Service/IpAddressMetadataResolverServiceTest.php b/tests/Feature/Service/IpAddressMetadataResolverServiceTest.php new file mode 100644 index 0000000000000000000000000000000000000000..dcb6c86b358cbf6c93a836a1711927efbfc5e395 --- /dev/null +++ b/tests/Feature/Service/IpAddressMetadataResolverServiceTest.php @@ -0,0 +1,51 @@ +<?php + +namespace Tests\Feature\Service; + +use App\Services\IpAddressMetadataResolverService; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Exceptions; +use Illuminate\Support\Facades\Http; +use PHPUnit\Framework\Attributes\CoversClass; +use SlProjects\LaravelRequestLogger\app\Models\IpAddress; +use Tests\TestCase; + +#[CoversClass(IpAddressMetadataResolverService::class)] +class IpAddressMetadataResolverServiceTest extends TestCase +{ + use RefreshDatabase; + + public function test_resolves_ip_addresses_successfully() + { + Exceptions::fake(); + + Http::fake([ + '*' => Http::response([ + [ + 'status' => 'success', + 'countryCode' => 'US', + 'lat' => 37.7892, + 'lon' => -122.402, + 'query' => '208.80.152.201', + ], + [ + 'status' => 'success', + 'countryCode' => 'CA', + 'lat' => 45.6085, + 'lon' => -73.5493, + 'query' => '24.48.0.1', + ], + ]), + ]); + + $service = new IpAddressMetadataResolverService; + + $ipAddresses = IpAddress::factory()->count(2)->create(); + $result = $service->resolve($ipAddresses); + + Exceptions::assertNothingReported(); + $this->assertCount(2, $result); + $this->assertEquals('US', $result[0]['countryCode']); + $this->assertEquals('CA', $result[1]['countryCode']); + } +}