Add weekly bank logo audit command (#211)

## Summary
- add a new `banks:check-logos` console command that validates all
non-null bank logo URLs weekly
- set broken/invalid bank logos to `null` and send an admin report email
to `ADMIN_EMAIL` when updates occur
- add weekly scheduling, admin mail config wiring, and feature tests for
valid/broken/head-fallback flows

## Testing
- vendor/bin/pint --dirty
- php artisan test tests/Feature/Console/CheckBankLogosCommandTest.php

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Víctor Falcón 2026-03-06 11:05:13 +00:00 committed by GitHub
parent dbc356ab7b
commit 2763846329
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 293 additions and 0 deletions

View File

@ -53,6 +53,7 @@ MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hi@whisper.money"
MAIL_FROM_NAME="Whisper Money"
ADMIN_EMAIL=
# Resend Email Service (set MAIL_MAILER=resend to use in production)
RESEND_API_KEY=

View File

@ -0,0 +1,112 @@
<?php
namespace App\Console\Commands;
use App\Mail\BrokenBankLogosReportEmail;
use App\Models\Bank;
use Illuminate\Console\Command;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
class CheckBankLogosCommand extends Command
{
protected $signature = 'banks:check-logos';
protected $description = 'Validate bank logo URLs and clear broken links';
public function handle(): int
{
$banks = Bank::query()
->whereNotNull('logo')
->get(['id', 'name', 'logo']);
if ($banks->isEmpty()) {
$this->info('No bank logos found to validate.');
return self::SUCCESS;
}
$updatedBanks = [];
foreach ($banks as $bank) {
$logoUrl = (string) $bank->logo;
if ($this->hasWorkingImage($logoUrl)) {
continue;
}
$bank->update(['logo' => null]);
$updatedBanks[] = [
'id' => $bank->id,
'name' => $bank->name,
'previous_logo' => $logoUrl,
];
$this->warn("Cleared broken logo for {$bank->name}.");
}
if ($updatedBanks === []) {
$this->info('All bank logos are valid.');
return self::SUCCESS;
}
$updatedCount = count($updatedBanks);
$this->info("Cleared broken logos for {$updatedCount} bank(s).");
$adminEmail = (string) config('mail.admin_email');
if ($adminEmail === '') {
$this->warn('ADMIN_EMAIL is not configured. Skipping report email.');
return self::SUCCESS;
}
Mail::to($adminEmail)->send(new BrokenBankLogosReportEmail($updatedBanks));
$this->info("Sent broken logo report to {$adminEmail}.");
return self::SUCCESS;
}
private function hasWorkingImage(string $logoUrl): bool
{
if (! filter_var($logoUrl, FILTER_VALIDATE_URL)) {
return false;
}
try {
$headResponse = Http::timeout(10)->head($logoUrl);
if ($headResponse->successful() && $this->isImageResponse($headResponse)) {
return true;
}
if ($headResponse->failed() && $headResponse->status() !== 405) {
return false;
}
$getResponse = Http::timeout(10)->get($logoUrl);
return $getResponse->successful() && $this->isImageResponse($getResponse);
} catch (ConnectionException) {
return false;
}
}
private function isImageResponse(Response $response): bool
{
$contentType = strtolower((string) $response->header('Content-Type'));
if ($contentType === '') {
return false;
}
return str_starts_with($contentType, 'image/')
|| str_contains($contentType, 'application/octet-stream');
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class BrokenBankLogosReportEmail extends Mailable
{
use Queueable, SerializesModels;
/**
* @param array<int, array{id: string, name: string, previous_logo: string}> $updatedBanks
*/
public function __construct(
public array $updatedBanks,
) {}
public function envelope(): Envelope
{
$updatedCount = count($this->updatedBanks);
return new Envelope(
subject: trans_choice('Weekly bank logo audit: :count broken logo|Weekly bank logo audit: :count broken logos', $updatedCount, ['count' => $updatedCount]),
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
}
public function content(): Content
{
return new Content(
markdown: 'mail.broken-bank-logos-report',
with: [
'updatedBanks' => $this->updatedBanks,
],
);
}
public function attachments(): array
{
return [];
}
}

View File

@ -15,6 +15,8 @@ return [
'drip_emails_enabled' => env('DRIP_EMAILS_ENABLED', true),
'admin_email' => env('ADMIN_EMAIL'),
/*
|--------------------------------------------------------------------------
| Email Verification

View File

@ -0,0 +1,15 @@
<x-mail::message>
# Weekly bank logo audit report
The weekly logo validation command found broken bank logo links and replaced them with `logo = null`.
**Updated banks:** {{ count($updatedBanks) }}
@foreach ($updatedBanks as $bank)
- **{{ $bank['name'] }}** (ID: {{ $bank['id'] }})
Previous logo: {{ $bank['previous_logo'] }}
@endforeach
Thanks,<br>
{{ config('app.name') }}
</x-mail::message>

View File

@ -6,3 +6,4 @@ Schedule::command('demo:reset')->twiceDaily();
Schedule::command('horizon:snapshot')->everyFiveMinutes();
Schedule::command('budgets:generate-periods')->daily();
Schedule::command('banking:sync')->everySixHours();
Schedule::command('banks:check-logos')->weekly();

View File

@ -0,0 +1,117 @@
<?php
use App\Mail\BrokenBankLogosReportEmail;
use App\Models\Bank;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use function Pest\Laravel\artisan;
test('command clears broken logos and emails admin report', function () {
Mail::fake();
config(['mail.admin_email' => 'admin@example.com']);
$validBank = Bank::factory()->create([
'name' => 'Valid Bank',
'logo' => 'https://bank-valid.test/logo.png',
]);
$brokenBank = Bank::factory()->create([
'name' => 'Broken Bank',
'logo' => 'https://bank-broken.test/logo.png',
]);
Http::fake([
'https://bank-valid.test/*' => Http::response('', 200, ['Content-Type' => 'image/png']),
'https://bank-broken.test/*' => Http::response('', 404),
]);
artisan('banks:check-logos')
->expectsOutputToContain('Cleared broken logos for 1 bank(s).')
->expectsOutputToContain('Sent broken logo report to admin@example.com.')
->assertSuccessful();
expect($validBank->fresh()->logo)->toBe('https://bank-valid.test/logo.png');
expect($brokenBank->fresh()->logo)->toBeNull();
Mail::assertSent(BrokenBankLogosReportEmail::class, function (BrokenBankLogosReportEmail $mail) use ($brokenBank) {
return $mail->updatedBanks === [[
'id' => $brokenBank->id,
'name' => 'Broken Bank',
'previous_logo' => 'https://bank-broken.test/logo.png',
]];
});
});
test('command does not send report when no broken logos are found', function () {
Mail::fake();
config(['mail.admin_email' => 'admin@example.com']);
$bank = Bank::factory()->create([
'logo' => 'https://bank-valid.test/logo.png',
]);
Http::fake([
'https://bank-valid.test/*' => Http::response('', 200, ['Content-Type' => 'image/png']),
]);
artisan('banks:check-logos')
->expectsOutputToContain('All bank logos are valid.')
->assertSuccessful();
expect($bank->fresh()->logo)->toBe('https://bank-valid.test/logo.png');
Mail::assertNothingSent();
});
test('command clears broken logos without emailing when ADMIN_EMAIL is missing', function () {
Mail::fake();
config(['mail.admin_email' => null]);
$brokenBank = Bank::factory()->create([
'name' => 'Broken Bank',
'logo' => 'https://bank-broken.test/logo.png',
]);
Http::fake([
'https://bank-broken.test/*' => Http::response('', 404),
]);
artisan('banks:check-logos')
->expectsOutputToContain('Cleared broken logos for 1 bank(s).')
->expectsOutputToContain('ADMIN_EMAIL is not configured. Skipping report email.')
->assertSuccessful();
expect($brokenBank->fresh()->logo)->toBeNull();
Mail::assertNothingSent();
});
test('command falls back to get request when head request is not allowed', function () {
Mail::fake();
config(['mail.admin_email' => null]);
$bank = Bank::factory()->create([
'logo' => 'https://bank-head-fallback.test/logo.png',
]);
Http::fake([
'https://bank-head-fallback.test/*' => function (Request $request) {
if ($request->method() === 'HEAD') {
return Http::response('', 405);
}
return Http::response('', 200, ['Content-Type' => 'image/png']);
},
]);
artisan('banks:check-logos')
->expectsOutputToContain('All bank logos are valid.')
->assertSuccessful();
expect($bank->fresh()->logo)->toBe('https://bank-head-fallback.test/logo.png');
Mail::assertNothingSent();
});