Add banks:set-logo artisan command (#213)
## Summary
- Adds `banks:set-logo {bank} {url}` artisan command to download,
process, and assign a logo to a bank by UUID
- Installs `intervention/image:^3.0` (GD driver) for image processing
- Image is validated to be square (returns an error if not), resized
down to max 250×250px if needed, stored as PNG on the `public` disk at
`banks/logos/{uuid}.png`, and the bank's `logo` field is updated with
the public URL
## Usage
```
php artisan banks:set-logo <bank-uuid> <image-url>
```
## Tests
7 feature tests covering success, no-resize, bank not found, HTTP
failure, non-image content type, non-square image, and invalid image
content.
This commit is contained in:
parent
3cbca1a17d
commit
2f1b9065f0
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Bank;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
|
||||
class SetBankLogoCommand extends Command
|
||||
{
|
||||
protected $signature = 'banks:set-logo
|
||||
{bank : The UUID of the bank}
|
||||
{url : The image URL to download}';
|
||||
|
||||
protected $description = 'Download an image from a URL, process it, and set it as the logo for a bank';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$bank = Bank::query()->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Bank;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
use function Pest\Laravel\artisan;
|
||||
|
||||
function makePng(int $width, int $height): string
|
||||
{
|
||||
$im = imagecreatetruecolor($width, $height);
|
||||
ob_start();
|
||||
imagepng($im);
|
||||
$contents = ob_get_clean();
|
||||
imagedestroy($im);
|
||||
|
||||
return (string) $contents;
|
||||
}
|
||||
|
||||
test('downloads and stores a square image as the bank logo', function () {
|
||||
Storage::fake('public');
|
||||
|
||||
$bank = Bank::factory()->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('<html></html>', 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();
|
||||
});
|
||||
Loading…
Reference in New Issue