Files
libra/tests/Unit/Services/AdminNotificationServiceTest.php
Naser Mansour 6ae226f5f2 Fix AdminNotificationService crashing on string throttle_minutes config
env('ERROR_NOTIFICATION_THROTTLE_MINUTES', 15) returns the literal string
"15" from production's .env (env() doesn't cast numeric strings), which
Carbon 3.11's addMinutes() rejects with a TypeError. That crash happened
before the throttle-dedup cache write, so it silently masked the *original*
exception on every notify-worthy error, indefinitely (discovered while
investigating a missing workshop-registration email — the real SMTP error
was hidden behind this crash). Cast to (int).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 00:45:27 +03:00

211 lines
7.5 KiB
PHP

<?php
use App\Contracts\ShouldNotifyAdmin;
use App\Notifications\SystemErrorNotification;
use App\Services\AdminNotificationService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\QueryException;
use Illuminate\Session\TokenMismatchException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Notification;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Mailer\Exception\TransportException;
beforeEach(function () {
config([
'libra.notifications.system_notifications_enabled' => true,
'libra.notifications.admin_email' => 'admin@test.com',
'libra.notifications.throttle_minutes' => 15,
]);
});
describe('shouldNotifyAdmin', function () {
it('returns false for validation exceptions', function () {
$service = new AdminNotificationService;
$exception = ValidationException::withMessages(['field' => ['error']]);
expect($service->shouldNotifyAdmin($exception))->toBeFalse();
});
it('returns false for authentication exceptions', function () {
$service = new AdminNotificationService;
$exception = new AuthenticationException;
expect($service->shouldNotifyAdmin($exception))->toBeFalse();
});
it('returns false for authorization exceptions', function () {
$service = new AdminNotificationService;
$exception = new AuthorizationException;
expect($service->shouldNotifyAdmin($exception))->toBeFalse();
});
it('returns false for model not found exceptions', function () {
$service = new AdminNotificationService;
$exception = new ModelNotFoundException;
expect($service->shouldNotifyAdmin($exception))->toBeFalse();
});
it('returns false for not found http exceptions', function () {
$service = new AdminNotificationService;
$exception = new NotFoundHttpException;
expect($service->shouldNotifyAdmin($exception))->toBeFalse();
});
it('returns false for token mismatch exceptions', function () {
$service = new AdminNotificationService;
$exception = new TokenMismatchException;
expect($service->shouldNotifyAdmin($exception))->toBeFalse();
});
it('returns true for exceptions implementing ShouldNotifyAdmin', function () {
$service = new AdminNotificationService;
$exception = new class extends Exception implements ShouldNotifyAdmin {};
expect($service->shouldNotifyAdmin($exception))->toBeTrue();
});
it('returns true for mail transport exceptions', function () {
$service = new AdminNotificationService;
$exception = new TransportException('SMTP error');
expect($service->shouldNotifyAdmin($exception))->toBeTrue();
});
it('returns true for critical database connection errors', function () {
$service = new AdminNotificationService;
$pdo = new PDOException('Connection refused');
$exception = new QueryException('mysql', 'SELECT 1', [], $pdo);
// Mock the code to be a critical code
$reflection = new ReflectionClass($exception);
$property = $reflection->getProperty('code');
$property->setAccessible(true);
$property->setValue($exception, '2002');
expect($service->shouldNotifyAdmin($exception))->toBeTrue();
});
it('returns false for non-critical database errors', function () {
$service = new AdminNotificationService;
$pdo = new PDOException('Syntax error');
$exception = new QueryException('mysql', 'SELECT 1', [], $pdo);
expect($service->shouldNotifyAdmin($exception))->toBeFalse();
});
});
describe('notifyError', function () {
it('sends notification when enabled', function () {
Notification::fake();
Cache::flush();
$service = new AdminNotificationService;
$exception = new RuntimeException('Test error');
$service->notifyError($exception);
Notification::assertSentOnDemand(
SystemErrorNotification::class,
function ($notification, $channels, $notifiable) {
return $notifiable->routes['mail'] === 'admin@test.com';
}
);
});
it('does not send notification when disabled', function () {
Notification::fake();
config(['libra.notifications.system_notifications_enabled' => false]);
$service = new AdminNotificationService;
$exception = new RuntimeException('Test error');
$service->notifyError($exception);
Notification::assertNothingSent();
});
it('does not send notification when admin email is not configured', function () {
Notification::fake();
config(['libra.notifications.admin_email' => null]);
$service = new AdminNotificationService;
$exception = new RuntimeException('Test error');
$service->notifyError($exception);
Notification::assertNothingSent();
});
it('rate limits duplicate notifications', function () {
Notification::fake();
Cache::flush();
$service = new AdminNotificationService;
$exception = new RuntimeException('Test error');
$service->notifyError($exception);
$service->notifyError($exception);
Notification::assertSentOnDemandTimes(SystemErrorNotification::class, 1);
});
it('allows notifications for different error types', function () {
Notification::fake();
Cache::flush();
$service = new AdminNotificationService;
$exception1 = new RuntimeException('Error 1');
$exception2 = new InvalidArgumentException('Error 2');
$service->notifyError($exception1);
$service->notifyError($exception2);
Notification::assertSentOnDemandTimes(SystemErrorNotification::class, 2);
});
it('allows notifications for same error type with different messages', function () {
Notification::fake();
Cache::flush();
$service = new AdminNotificationService;
$exception1 = new RuntimeException('Error message 1');
$exception2 = new RuntimeException('Error message 2');
$service->notifyError($exception1);
$service->notifyError($exception2);
Notification::assertSentOnDemandTimes(SystemErrorNotification::class, 2);
});
it('does not throw and still sends notification when throttle_minutes config is a string', function () {
// Reproduces a live production bug: env('ERROR_NOTIFICATION_THROTTLE_MINUTES', 15) returns
// the string "15" (env() never casts numeric-looking strings to int), which used to be
// passed straight into now()->addMinutes(), and Carbon 3.11 throws a TypeError for a
// string argument there. That TypeError masked the original exception being reported and
// notifyError() never actually sent the admin notification.
Notification::fake();
Cache::flush();
config(['libra.notifications.throttle_minutes' => '15']);
$service = new AdminNotificationService;
$exception = new TransportException('SMTP error');
$service->notifyError($exception);
Notification::assertSentOnDemand(
SystemErrorNotification::class,
function ($notification, $channels, $notifiable) {
return $notifiable->routes['mail'] === 'admin@test.com';
}
);
});
});