diff --git a/app/Console/Commands/SetBankLogoCommand.php b/app/Console/Commands/SetBankLogoCommand.php new file mode 100644 index 00000000..ecb87482 --- /dev/null +++ b/app/Console/Commands/SetBankLogoCommand.php @@ -0,0 +1,92 @@ +find($this->argument('bank')); + + if ($bank === null) { + $this->error("Bank not found: {$this->argument('bank')}"); + + return self::FAILURE; + } + + $url = $this->argument('url'); + + try { + $response = Http::timeout(30)->get($url); + } catch (ConnectionException $e) { + $this->error("Failed to connect to URL: {$e->getMessage()}"); + + return self::FAILURE; + } + + if ($response->failed()) { + $this->error("Failed to download image: HTTP {$response->status()}"); + + return self::FAILURE; + } + + $contentType = strtolower((string) $response->header('Content-Type')); + + if (! str_starts_with($contentType, 'image/')) { + $this->error("URL does not point to an image (Content-Type: {$contentType})."); + + return self::FAILURE; + } + + try { + $manager = new ImageManager(new Driver); + $image = $manager->read($response->body()); + } catch (\Exception $e) { + $this->error("Downloaded file is not a valid image: {$e->getMessage()}"); + + return self::FAILURE; + } + + if ($image->width() !== $image->height()) { + $this->error("Image is not square ({$image->width()}×{$image->height()}) — skipping."); + + return self::FAILURE; + } + + $originalSize = $image->width(); + + $this->info("Image downloaded ({$originalSize}×{$originalSize}px)."); + + if ($image->width() > 250) { + $image->scaleDown(250, 250); + $this->info("Resized to {$image->width()}×{$image->height()}px."); + } + + $path = "banks/logos/{$bank->id}.png"; + + Storage::disk('public')->put($path, $image->toPng()->toString()); + + $logoUrl = Storage::disk('public')->url($path); + + $bank->update(['logo' => $logoUrl]); + + $this->info("Logo updated for \"{$bank->name}\"."); + $this->info("Stored at: {$logoUrl}"); + + return self::SUCCESS; + } +} diff --git a/composer.json b/composer.json index 91a8ce9e..24b8ab69 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "php": "^8.2", "firebase/php-jwt": "^7.0", "inertiajs/inertia-laravel": "^2.0", + "intervention/image": "^3.0", "jwadhams/json-logic-php": "^1.5", "laravel/cashier": "^16.1", "laravel/fortify": "^1.30", diff --git a/composer.lock b/composer.lock index 7e7d9d67..20db425f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "481c17881a66827f27d14db91425942d", + "content-hash": "f3e9158328d5e5f9298df373170b2778", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1290,6 +1290,150 @@ }, "time": "2026-02-13T11:53:06+00:00" }, + { + "name": "intervention/gif", + "version": "4.2.4", + "source": { + "type": "git", + "url": "https://github.com/Intervention/gif.git", + "reference": "c3598a16ebe7690cd55640c44144a9df383ea73c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/gif/zipball/c3598a16ebe7690cd55640c44144a9df383ea73c", + "reference": "c3598a16ebe7690cd55640c44144a9df383ea73c", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Gif\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io/" + } + ], + "description": "Native PHP GIF Encoder/Decoder", + "homepage": "https://github.com/intervention/gif", + "keywords": [ + "animation", + "gd", + "gif", + "image" + ], + "support": { + "issues": "https://github.com/Intervention/gif/issues", + "source": "https://github.com/Intervention/gif/tree/4.2.4" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2026-01-04T09:27:23+00:00" + }, + { + "name": "intervention/image", + "version": "3.11.7", + "source": { + "type": "git", + "url": "https://github.com/Intervention/image.git", + "reference": "2159bcccff18f09d2a392679b81a82c5a003f9bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/image/zipball/2159bcccff18f09d2a392679b81a82c5a003f9bb", + "reference": "2159bcccff18f09d2a392679b81a82c5a003f9bb", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "intervention/gif": "^4.2", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "suggest": { + "ext-exif": "Recommended to be able to read EXIF data properly." + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Image\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io" + } + ], + "description": "PHP Image Processing", + "homepage": "https://image.intervention.io", + "keywords": [ + "gd", + "image", + "imagick", + "resize", + "thumbnail", + "watermark" + ], + "support": { + "issues": "https://github.com/Intervention/image/issues", + "source": "https://github.com/Intervention/image/tree/3.11.7" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2026-02-19T13:11:17+00:00" + }, { "name": "jean85/pretty-package-versions", "version": "2.1.1", diff --git a/tests/Feature/Console/SetBankLogoCommandTest.php b/tests/Feature/Console/SetBankLogoCommandTest.php new file mode 100644 index 00000000..62359b0b --- /dev/null +++ b/tests/Feature/Console/SetBankLogoCommandTest.php @@ -0,0 +1,113 @@ +create(['logo' => null]); + $squarePng = makePng(300, 300); + + Http::fake([ + 'https://example.test/logo.png' => Http::response($squarePng, 200, ['Content-Type' => 'image/png']), + ]); + + artisan('banks:set-logo', ['bank' => $bank->id, 'url' => 'https://example.test/logo.png']) + ->expectsOutputToContain('Image downloaded (300×300px).') + ->expectsOutputToContain('Resized to 250×250px.') + ->expectsOutputToContain("Logo updated for \"{$bank->name}\".") + ->assertSuccessful(); + + Storage::disk('public')->assertExists("banks/logos/{$bank->id}.png"); + expect($bank->fresh()->logo)->not->toBeNull(); +}); + +test('does not resize images already within 250px', function () { + Storage::fake('public'); + + $bank = Bank::factory()->create(['logo' => null]); + $squarePng = makePng(100, 100); + + Http::fake([ + 'https://example.test/logo.png' => Http::response($squarePng, 200, ['Content-Type' => 'image/png']), + ]); + + artisan('banks:set-logo', ['bank' => $bank->id, 'url' => 'https://example.test/logo.png']) + ->expectsOutputToContain('Image downloaded (100×100px).') + ->doesntExpectOutputToContain('Resized to') + ->assertSuccessful(); + + Storage::disk('public')->assertExists("banks/logos/{$bank->id}.png"); +}); + +test('fails when bank uuid does not exist', function () { + artisan('banks:set-logo', ['bank' => 'non-existent-uuid', 'url' => 'https://example.test/logo.png']) + ->expectsOutputToContain('Bank not found: non-existent-uuid') + ->assertFailed(); +}); + +test('fails when http request returns an error status', function () { + $bank = Bank::factory()->create(); + + Http::fake([ + 'https://example.test/logo.png' => Http::response('', 404), + ]); + + artisan('banks:set-logo', ['bank' => $bank->id, 'url' => 'https://example.test/logo.png']) + ->expectsOutputToContain('Failed to download image: HTTP 404') + ->assertFailed(); +}); + +test('fails when url does not point to an image', function () { + $bank = Bank::factory()->create(); + + Http::fake([ + 'https://example.test/page' => Http::response('', 200, ['Content-Type' => 'text/html']), + ]); + + artisan('banks:set-logo', ['bank' => $bank->id, 'url' => 'https://example.test/page']) + ->expectsOutputToContain('URL does not point to an image') + ->assertFailed(); +}); + +test('fails when image is not square', function () { + $bank = Bank::factory()->create(['logo' => null]); + $rectangularPng = makePng(400, 200); + + Http::fake([ + 'https://example.test/logo.png' => Http::response($rectangularPng, 200, ['Content-Type' => 'image/png']), + ]); + + artisan('banks:set-logo', ['bank' => $bank->id, 'url' => 'https://example.test/logo.png']) + ->expectsOutputToContain('Image is not square (400×200) — skipping.') + ->assertFailed(); + + expect($bank->fresh()->logo)->toBeNull(); +}); + +test('fails when downloaded content is not a valid image', function () { + $bank = Bank::factory()->create(); + + Http::fake([ + 'https://example.test/logo.png' => Http::response('not-an-image', 200, ['Content-Type' => 'image/png']), + ]); + + artisan('banks:set-logo', ['bank' => $bank->id, 'url' => 'https://example.test/logo.png']) + ->expectsOutputToContain('Downloaded file is not a valid image') + ->assertFailed(); +});