diff --git a/app/Enums/Monitors/MonitorType.php b/app/Enums/Monitors/MonitorType.php index de066aa..d9cee8b 100644 --- a/app/Enums/Monitors/MonitorType.php +++ b/app/Enums/Monitors/MonitorType.php @@ -2,6 +2,7 @@ namespace App\Enums\Monitors; +use App\Jobs\Checks\DummyCheckJob; use App\Jobs\Checks\HttpCheckJob; use App\Jobs\Checks\TcpCheckJob; use Filament\Support\Contracts\HasIcon; @@ -11,12 +12,14 @@ enum MonitorType: string implements HasIcon, HasLabel { case HTTP = 'http'; case TCP = 'tcp'; + case DUMMY = 'dummy'; public function toCheckJob(): string { return match ($this) { self::HTTP => HttpCheckJob::class, self::TCP => TcpCheckJob::class, + self::DUMMY => DummyCheckJob::class, }; } diff --git a/app/Jobs/Checks/CheckJob.php b/app/Jobs/Checks/CheckJob.php index 822bdd0..262fafd 100644 --- a/app/Jobs/Checks/CheckJob.php +++ b/app/Jobs/Checks/CheckJob.php @@ -20,92 +20,94 @@ abstract class CheckJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + protected float $startTime; + protected float $endTime; + public function __construct(protected Monitor $monitor) { } public function handle(): void { - $startTime = microtime(true); + $this->startTime = microtime(true); try { $result = $this->performCheck(); - $endTime = microtime(true); - - $checkStatus = $result['status'] ?? Status::FAIL; - $responseTime = $this->calculateResponseTime($startTime, $endTime); - $responseCode = $result['response_code'] ?? null; - $output = $result['output'] ?? null; - - DB::transaction(function () use ($checkStatus, $responseTime, $responseCode, $output) { - // Create the check record - $check = new Check([ - 'status' => $checkStatus, - 'response_time' => $responseTime, - 'response_code' => $responseCode, - 'output' => $output, - 'checked_at' => now(), - ]); - - $this->monitor->checks()->save($check); - - // Count recent checks with the same status - $recentChecks = $this->monitor->checks() - ->latest('checked_at') - ->take($this->monitor->consecutive_threshold) - ->get(); - - // Only update status if we have enough consecutive checks with the same status - if ($recentChecks->count() >= $this->monitor->consecutive_threshold && - $recentChecks->every(fn($check) => $check->status === $checkStatus)) { - $this->monitor->update(['status' => $checkStatus]); - } - }); - - } catch (ConnectionException $exception) { - $this->fail($exception, $startTime); - } catch (Exception $exception) { - $this->fail($exception, $startTime); - - Log::error('Failed to perform monitor check ' . $this->monitor->id . ': ' . $exception->getMessage()); - Sentry::captureException($exception); + $this->endTime = microtime(true); + + $this->processResult($result); + } catch (ConnectionException|Exception $exception) { + $this->handleException($exception); } + + $this->monitor->updateNextCheck(); } abstract protected function performCheck(): array; - protected function calculateResponseTime(float $startTime, float $endTime): float + protected function processResult(array $result): void { - return ($endTime - $startTime) * 1000; // Convert to milliseconds + DB::transaction(function () use ($result) { + $check = $this->createCheck($result); + $this->updateMonitorStatus($check->status); + }); } - protected function fail(Exception $exception, $startTime): void + protected function handleException(Exception $exception): void { - $endTime = microtime(true); + $this->endTime = microtime(true); - DB::transaction(function () use ($startTime, $endTime, $exception) { - // Create the check record - $check = new Check([ + if (!$exception instanceof ConnectionException) { + Log::error("Failed to perform monitor check {$this->monitor->id}: {$exception->getMessage()}"); + Sentry::captureException($exception); + } + + $check = $this->createCheck([ 'status' => Status::FAIL, - 'response_time' => $this->calculateResponseTime($startTime, $endTime), - 'response_code' => null, 'output' => $exception->getMessage(), - 'checked_at' => now(), ]); - $this->monitor->checks()->save($check); + $this->updateMonitorStatus($check->status); + } - // Count recent failures - $recentChecks = $this->monitor->checks() - ->latest('checked_at') - ->take($this->monitor->consecutive_threshold) - ->get(); + protected function createCheck(array $result): Check + { + + $check = new Check([ + 'status' => $result['status'] ?? Status::FAIL, + 'response_time' => $this->calculateResponseTime(), + 'response_code' => $result['response_code'] ?? null, + 'output' => $result['output'] ?? null, + 'checked_at' => now(), + ]); + + $this->monitor->checks()->save($check); + + return $check; + } - // Only update status if we have enough consecutive failures - if ($recentChecks->count() >= $this->monitor->consecutive_threshold && - $recentChecks->every(fn($check) => $check->status === Status::FAIL)) { - $this->monitor->update(['status' => Status::FAIL]); + protected function updateMonitorStatus(Status $newStatus): void + { + $this->monitor->refresh(); + // Get the most recent checks, including the current one + $recentChecks = $this->monitor->checks() + ->latest('checked_at') + ->take($this->monitor->consecutive_threshold) + ->get(); + + // Only update status if we have enough checks and they all have the same status + if ($recentChecks->count() >= $this->monitor->consecutive_threshold) { + $allSameStatus = $recentChecks->every(fn($check) => $check->status === $newStatus); + + if ($allSameStatus) { + $this->monitor->status = $newStatus; + $this->monitor->save(); } - }); + } + } + + protected function calculateResponseTime(): float + { + return ($this->endTime - $this->startTime) * 1000; // Convert to milliseconds } } diff --git a/app/Jobs/Checks/DummyCheckJob.php b/app/Jobs/Checks/DummyCheckJob.php new file mode 100644 index 0000000..51704ed --- /dev/null +++ b/app/Jobs/Checks/DummyCheckJob.php @@ -0,0 +1,27 @@ +returnStatus = $returnStatus; + } + + protected function performCheck(): array + { + usleep(random_int(100000, 123456)); + return [ + 'status' => $this->returnStatus, + 'response_code' => 200, + 'output' => 'test output', + ]; + } +} diff --git a/app/Jobs/Checks/TcpCheckJob.php b/app/Jobs/Checks/TcpCheckJob.php index be30ae4..26324cc 100644 --- a/app/Jobs/Checks/TcpCheckJob.php +++ b/app/Jobs/Checks/TcpCheckJob.php @@ -3,28 +3,34 @@ namespace App\Jobs\Checks; use App\Enums\Checks\Status; +use App\Services\TcpConnectionService; use Exception; class TcpCheckJob extends CheckJob { - protected function performCheck(): array + protected TcpConnectionService $tcpService; + + public function __construct($monitor, ?TcpConnectionService $tcpService = null) { - $socket = @fsockopen( - $this->monitor->address, - $this->monitor->port ?? 80, - $errno, - $errstr, - timeout: 5 - ); + parent::__construct($monitor); + $this->tcpService = $tcpService ?? new TcpConnectionService(); + } - if (!$socket) { - throw new Exception($errstr, $errno); - } + protected function performCheck(): array + { + try { + $socket = $this->tcpService->connect( + $this->monitor->address, + $this->monitor->port ?? 80 + ); - fclose($socket); + $this->tcpService->close($socket); - return [ - 'status' => Status::OK, - ]; + return [ + 'status' => Status::OK, + ]; + } catch (Exception $e) { + throw $e; + } } } diff --git a/app/Models/Check.php b/app/Models/Check.php index b047bb4..c3d0a71 100644 --- a/app/Models/Check.php +++ b/app/Models/Check.php @@ -9,11 +9,11 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; - +use Illuminate\Database\Eloquent\Factories\HasFactory; #[ObservedBy(CheckObserver::class)] class Check extends Model { - use HasUlids, SoftDeletes; + use HasUlids, SoftDeletes, HasFactory; protected $guarded = []; diff --git a/app/Models/Monitor.php b/app/Models/Monitor.php index fe59f8e..781426a 100644 --- a/app/Models/Monitor.php +++ b/app/Models/Monitor.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -20,7 +21,7 @@ #[ObservedBy(UserIdObserver::class)] class Monitor extends Model { - use HasUlids; + use HasUlids, HasFactory; protected $guarded = []; diff --git a/app/Services/TcpConnectionService.php b/app/Services/TcpConnectionService.php new file mode 100644 index 0000000..e621b48 --- /dev/null +++ b/app/Services/TcpConnectionService.php @@ -0,0 +1,50 @@ + + */ +class CheckFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'id' => Ulid::generate(), + 'status' => Status::OK, + 'checked_at' => now(), + 'monitor_id' => Monitor::factory(), + ]; + } +} diff --git a/database/factories/MonitorFactory.php b/database/factories/MonitorFactory.php index a26e36a..30ce54d 100644 --- a/database/factories/MonitorFactory.php +++ b/database/factories/MonitorFactory.php @@ -7,6 +7,7 @@ use App\Models\Monitor; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; +use Symfony\Component\Uid\Ulid; class MonitorFactory extends Factory { @@ -15,6 +16,7 @@ class MonitorFactory extends Factory public function definition(): array { return [ + 'name' => $this->faker->words(3, true), 'type' => $this->faker->randomElement(MonitorType::cases()), 'address' => $this->faker->url, @@ -22,7 +24,7 @@ public function definition(): array 'interval' => $this->faker->randomElement([1, 5, 15, 30, 60]), 'consecutive_threshold' => $this->faker->randomElement([1, 2, 3]), 'is_enabled' => true, - 'status' => Status::OK, + 'status' => Status::UNKNOWN, 'user_id' => User::factory(), 'next_check_at' => now(), ]; diff --git a/tests/Pest.php b/tests/Pest.php index 599d4ee..329824f 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,7 +2,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Notification; - +use Illuminate\Support\Facades\Queue; /* |-------------------------------------------------------------------------- | Test Case @@ -20,6 +20,7 @@ ->beforeEach(function () { // Fake all notifications Notification::fake(); + Queue::fake(); }); /* diff --git a/tests/TestCase.php b/tests/TestCase.php index f4dbb4f..2f79bb6 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,5 +8,5 @@ abstract class TestCase extends BaseTestCase { - protected $seed = true; + } diff --git a/tests/Unit/Jobs/Checks/CheckJobTest.php b/tests/Unit/Jobs/Checks/CheckJobTest.php new file mode 100644 index 0000000..edabf89 --- /dev/null +++ b/tests/Unit/Jobs/Checks/CheckJobTest.php @@ -0,0 +1,91 @@ +create(array_merge([ + 'type' => MonitorType::DUMMY, + 'status' => Status::UNKNOWN, + 'consecutive_threshold' => 2, + 'interval' => 1, + 'is_enabled' => true, + 'next_check_at' => now(), + 'user_id' => User::factory(), + 'address' => 'https://example.com', + 'port' => null, + ], $attributes)); +} + +beforeEach(function () { + Bus::fake([ + TriggerAlertJob::class, + ]); +}); + +it('creates a check record', function () { + $monitor = createMonitor(); + $job = new DummyCheckJob($monitor); + $job->handle(); + + expect($monitor->checks)->toHaveCount(1) + ->and($monitor->checks->first()) + ->toBeInstanceOf(Check::class) + ->status->toBe(Status::OK) + ->response_code->toBe(200) + ->output->toBe('test output'); + + Bus::assertDispatched(TriggerAlertJob::class); +}); + +it('handles exceptions gracefully', function () { + $monitor = createMonitor(); + $job = new class($monitor) extends DummyCheckJob { + protected function performCheck(): array + { + throw new Exception('Test exception'); + } + }; + + $job->handle(); + + expect($monitor->checks)->toHaveCount(1) + ->and($monitor->checks->first()) + ->status->toBe(Status::FAIL) + ->output->toBe('Test exception'); + + Bus::assertDispatched(TriggerAlertJob::class); +}); + +it('updates next check time after completion', function () { + $monitor = createMonitor(); + $originalNextCheck = $monitor->next_check_at; + + $job = new DummyCheckJob($monitor); + $job->handle(); + + expect($monitor->fresh()->next_check_at) + ->not->toBe($originalNextCheck); + + Bus::assertDispatched(TriggerAlertJob::class); +}); + +it('calculates response time correctly', function () { + $monitor = createMonitor(); + $job = new DummyCheckJob($monitor); + $job->handle(); + + expect($monitor->checks->first()->response_time) + ->toBeFloat() + ->toBeGreaterThan(0) + ->toBeLessThan(1000); // Should be less than 1 second for a dummy check + + Bus::assertDispatched(TriggerAlertJob::class); +}); diff --git a/tests/Unit/Jobs/Checks/HttpCheckJobTest.php b/tests/Unit/Jobs/Checks/HttpCheckJobTest.php new file mode 100644 index 0000000..ada4267 --- /dev/null +++ b/tests/Unit/Jobs/Checks/HttpCheckJobTest.php @@ -0,0 +1,72 @@ +monitor = Monitor::factory()->http()->create(); +}); + +it('marks check as successful when http request succeeds', function () { + Http::fake([ + '*' => Http::response('OK', 200, ['X-Test' => 'test']), + ]); + + $job = new HttpCheckJob($this->monitor); + $job->handle(); + + expect($this->monitor->checks)->toHaveCount(1) + ->and($this->monitor->checks->first()) + ->status->toBe(Status::OK) + ->response_code->toBe(200) + ->output->toBeJson() + ->and(json_decode($this->monitor->checks->first()->output)) + ->reason->toBe('OK'); +}); + +it('marks check as failed when http request fails', function () { + Http::fake([ + '*' => Http::response('Server Error', 500), + ]); + + $job = new HttpCheckJob($this->monitor); + $job->handle(); + + expect($this->monitor->checks)->toHaveCount(1) + ->and($this->monitor->checks->first()) + ->status->toBe(Status::FAIL) + ->response_code->toBe(500); +}); + +it('handles connection exceptions', function () { + Http::fake([ + '*' => function() { + throw new ConnectionException(); + }, + ]); + + $job = new HttpCheckJob($this->monitor); + $job->handle(); + + expect($this->monitor->checks)->toHaveCount(1) + ->and($this->monitor->checks->first()) + ->status->toBe(Status::FAIL) + ->output->toBeJson(); +}); + +it('uses custom user agent when specified', function () { + $this->monitor->user_agent = 'CustomBot/1.0'; + $this->monitor->save(); + + Http::fake(); + + $job = new HttpCheckJob($this->monitor); + $job->handle(); + + Http::assertSent(function ($request) { + return $request->header('User-Agent')[0] === 'CustomBot/1.0'; + }); +}); diff --git a/tests/Unit/Jobs/Checks/TcpCheckJobTest.php b/tests/Unit/Jobs/Checks/TcpCheckJobTest.php new file mode 100644 index 0000000..63502bf --- /dev/null +++ b/tests/Unit/Jobs/Checks/TcpCheckJobTest.php @@ -0,0 +1,65 @@ +monitor = Monitor::factory()->tcp()->create(); + $this->tcpService = Mockery::mock(TcpConnectionService::class); +}); + +it('marks check as successful when tcp connection succeeds', function () { + $socket = fopen('php://memory', 'r+'); + + $this->tcpService->shouldReceive('connect') + ->once() + ->with($this->monitor->address, $this->monitor->port) + ->andReturn($socket); + + $this->tcpService->shouldReceive('close') + ->once() + ->with($socket); + + $job = new TcpCheckJob($this->monitor, $this->tcpService); + $job->handle(); + + expect($this->monitor->checks)->toHaveCount(1) + ->and($this->monitor->checks->first()) + ->status->toBe(Status::OK); +}); + +it('marks check as failed when tcp connection fails', function () { + $this->tcpService->shouldReceive('connect') + ->once() + ->with($this->monitor->address, $this->monitor->port) + ->andThrow(new Exception('Connection failed')); + + $job = new TcpCheckJob($this->monitor, $this->tcpService); + $job->handle(); + + expect($this->monitor->checks)->toHaveCount(1) + ->and($this->monitor->checks->first()) + ->status->toBe(Status::FAIL) + ->output->toBe('Connection failed'); +}); + +it('uses default port 80 when not specified', function () { + $this->monitor->port = null; + $this->monitor->save(); + + $socket = fopen('php://memory', 'r+'); + + $this->tcpService->shouldReceive('connect') + ->once() + ->with($this->monitor->address, 80) + ->andReturn($socket); + + $this->tcpService->shouldReceive('close') + ->once() + ->with($socket); + + $job = new TcpCheckJob($this->monitor, $this->tcpService); + $job->handle(); +}); diff --git a/tests/Unit/Jobs/TriggerAlertJobTest.php b/tests/Unit/Jobs/TriggerAlertJobTest.php new file mode 100644 index 0000000..d19649e --- /dev/null +++ b/tests/Unit/Jobs/TriggerAlertJobTest.php @@ -0,0 +1,226 @@ +create([ + 'status' => Status::UNKNOWN, + 'consecutive_threshold' => 2, + 'is_enabled' => true, + 'type' => MonitorType::DUMMY, + ]); + + // First failure + $firstCheck = Check::factory()->create([ + 'monitor_id' => $monitor->id, + 'status' => Status::FAIL, + 'checked_at' => now(), + ]); + + (new TriggerAlertJob($firstCheck))->handle(); + + $monitor->refresh(); + + expect($monitor->anomalies->count())->toBe(0) + ->and($monitor->status)->toBe(Status::FAIL); + + // Second failure - should create anomaly + $secondCheck = Check::factory()->create([ + 'monitor_id' => $monitor->id, + 'status' => Status::FAIL, + 'checked_at' => now()->addMinute(), + ]); + + (new TriggerAlertJob($secondCheck))->handle(); + + $monitor->refresh(); + + expect($monitor->anomalies->count())->toBe(1) + ->and($monitor->status)->toBe(Status::FAIL); +}); + +it('associates checks with anomaly', function () { + $monitor = Monitor::factory()->create([ + 'status' => Status::UNKNOWN, + 'consecutive_threshold' => 2, + 'interval' => 1, + 'is_enabled' => true, + 'type' => MonitorType::DUMMY, + ]); + + // Create two failing checks + $checks = Check::factory()->count(2)->create([ + 'monitor_id' => $monitor->id, + 'status' => Status::FAIL, + 'checked_at' => now(), + ]); + + // Process both checks + $checks->each(fn ($check) => (new TriggerAlertJob($check))->handle()); + + $monitor->refresh(); + + $anomaly = $monitor->anomalies->first(); + + expect(value: $anomaly->checks->count())->toBe(2) + ->and($anomaly->checks->pluck('id'))->toEqual($checks->pluck('id')); +}); + +it('closes anomaly after consecutive successes', function () { + $monitor = Monitor::factory()->create([ + 'status' => Status::UNKNOWN, + 'consecutive_threshold' => 2, + 'is_enabled' => true, + 'interval' => 1, + 'type' => MonitorType::DUMMY, + ]); + + // Create initial failing checks to create anomaly + $failingChecks = collect([ + Check::factory()->create([ + 'monitor_id' => $monitor->id, + 'status' => Status::FAIL, + 'checked_at' => now()->subMinutes(3), + ]), + Check::factory()->create([ + 'monitor_id' => $monitor->id, + 'status' => Status::FAIL, + 'checked_at' => now()->subMinutes(2), + ]), + ]); + + $failingChecks->each(fn ($check) => (new TriggerAlertJob($check))->handle()); + + // Now create successful checks + $successChecks = collect([ + Check::factory()->create([ + 'monitor_id' => $monitor->id, + 'status' => Status::OK, + 'checked_at' => now()->subMinute(), + ]), + Check::factory()->create([ + 'monitor_id' => $monitor->id, + 'status' => Status::OK, + 'checked_at' => now(), + ]), + ]); + + $successChecks->each(fn ($check) => (new TriggerAlertJob($check))->handle()); + + $monitor->refresh(); + $anomaly = $monitor->anomalies->first(); + + expect($monitor->status)->toBe(Status::OK) + ->and($anomaly->ended_at)->not->toBeNull(); +}); + +it('maintains anomaly during mixed status checks', function () { + $monitor = Monitor::factory()->create([ + 'status' => Status::UNKNOWN, + 'consecutive_threshold' => 2, + 'is_enabled' => true, + 'type' => MonitorType::DUMMY, + ]); + + // Create initial failing checks to create anomaly + collect([ + Check::factory()->create([ + 'monitor_id' => $monitor->id, + 'status' => Status::FAIL, + 'checked_at' => now()->subMinutes(4), + ]), + Check::factory()->create([ + 'monitor_id' => $monitor->id, + 'status' => Status::FAIL, + 'checked_at' => now()->subMinutes(3), + ]), + ])->each(fn ($check) => (new TriggerAlertJob($check))->handle()); + + // Add mixed status checks + collect([ + Check::factory()->create([ + 'monitor_id' => $monitor->id, + 'status' => Status::OK, + 'checked_at' => now()->subMinutes(2), + ]), + Check::factory()->create([ + 'monitor_id' => $monitor->id, + 'status' => Status::FAIL, + 'checked_at' => now()->subMinute(), + ]), + ])->each(fn ($check) => (new TriggerAlertJob($check))->handle()); + + $monitor->refresh(); + $anomaly = $monitor->anomalies->first(); + + expect($monitor->status)->toBe(Status::FAIL) + ->and($anomaly->ended_at)->toBeNull() + ->and($anomaly->checks)->toHaveCount(count: 3); +}); + +it('handles multiple anomalies for the same monitor', function () { + $monitor = Monitor::factory()->create([ + 'status' => Status::UNKNOWN, + 'consecutive_threshold' => 2, + ]); + + // First anomaly + collect([ + Check::factory()->create([ + 'monitor_id' => $monitor->id, + 'status' => Status::FAIL, + 'checked_at' => now()->subMinutes(5), + ]), + Check::factory()->create([ + 'monitor_id' => $monitor->id, + 'status' => Status::FAIL, + 'checked_at' => now()->subMinutes(4), + ]), + ])->each(fn ($check) => (new TriggerAlertJob($check))->handle()); + + // Recovery + collect([ + Check::factory()->create([ + 'monitor_id' => $monitor->id, + 'status' => Status::OK, + 'checked_at' => now()->subMinutes(3), + ]), + Check::factory()->create([ + 'monitor_id' => $monitor->id, + 'status' => Status::OK, + 'checked_at' => now()->subMinutes(2), + ]), + ])->each(fn ($check) => (new TriggerAlertJob($check))->handle()); + + // Second anomaly + collect([ + Check::factory()->create([ + 'monitor_id' => $monitor->id, + 'status' => Status::FAIL, + 'checked_at' => now()->subMinute(), + ]), + Check::factory()->create([ + 'monitor_id' => $monitor->id, + 'status' => Status::FAIL, + 'checked_at' => now(), + ]), + ])->each(fn ($check) => (new TriggerAlertJob($check))->handle()); + + expect(Anomaly::count())->toBe(2) + ->and(Anomaly::whereNotNull('ended_at')->count())->toBe(1) + ->and(Anomaly::whereNull('ended_at')->count())->toBe(1) + ->and($monitor->fresh()->status)->toBe(Status::FAIL); +});