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:
parent
dbc356ab7b
commit
2763846329
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,8 @@ return [
|
|||
|
||||
'drip_emails_enabled' => env('DRIP_EMAILS_ENABLED', true),
|
||||
|
||||
'admin_email' => env('ADMIN_EMAIL'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Email Verification
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue