diff --git a/app/Console/Commands/CheckLinksCommand.php b/app/Console/Commands/CheckLinksCommand.php index 314d4e041..4b91e6b94 100644 --- a/app/Console/Commands/CheckLinksCommand.php +++ b/app/Console/Commands/CheckLinksCommand.php @@ -63,7 +63,14 @@ protected function getLinks(User $user): Collection ->where('check_disabled', false) ->where(function ($query) { $query->where('last_checked_at', '<', now()->subMonths(2)) - ->orWhereNull('last_checked_at'); + ->orWhereNull('last_checked_at') + ->orWhere(function ($q) { + // Re-check broken links more frequently than the standard 2-month cycle + $q->where('status', Link::STATUS_BROKEN) + ->where('last_checked_at', '<', now()->subWeeks( + config('linkace.link_checks.broken_recheck_interval_weeks') + )); + }); }) ->where('url', 'LIKE', 'http%') ->oldest('id') diff --git a/app/Repositories/LinkRepository.php b/app/Repositories/LinkRepository.php index 9a6d94cc8..802d43118 100644 --- a/app/Repositories/LinkRepository.php +++ b/app/Repositories/LinkRepository.php @@ -36,9 +36,8 @@ public static function create(array $data, bool $flashAlerts = false): Link $data['icon'] = LinkIconMapper::getIconForUrl($data['url']); $data['thumbnail'] = $linkMeta['thumbnail']; - // If the meta helper was not successful, disable future checks and set the status to broken + // If the meta helper was not successful, set the status to broken so it can be re-checked later if ($linkMeta['success'] === false) { - $data['check_disabled'] = true; $data['status'] = Link::STATUS_BROKEN; } @@ -62,6 +61,12 @@ public static function update(Link $link, array $data): Link { $data['icon'] = LinkIconMapper::getIconForUrl($data['url'] ?? $link->url); + // If the URL changed and the link was broken, reset status so it gets re-checked + if (isset($data['url']) && $data['url'] !== $link->url && $link->status === Link::STATUS_BROKEN) { + $data['status'] = Link::STATUS_OK; + $data['last_checked_at'] = null; + } + $link->update($data); self::processLinkTaxonomies($link, $data); diff --git a/config/linkace.php b/config/linkace.php index ff3160ca2..81410e6c1 100644 --- a/config/linkace.php +++ b/config/linkace.php @@ -7,6 +7,11 @@ 'cache_duration' => 3600, // 60 minutes ], + 'link_checks' => [ + // Number of weeks between re-checks of broken links + 'broken_recheck_interval_weeks' => (int) env('BROKEN_LINK_RECHECK_INTERVAL_WEEKS', 2), + ], + 'listitem_count_values' => [ 12, 24, diff --git a/tests/Commands/CheckLinksCommandTest.php b/tests/Commands/CheckLinksCommandTest.php index 58e6fb4f0..b280df03a 100644 --- a/tests/Commands/CheckLinksCommandTest.php +++ b/tests/Commands/CheckLinksCommandTest.php @@ -177,4 +177,56 @@ public function test_check_with_limit(): void fn (LinkCheckNotification $notification) => count($notification->brokenLinks) === 5 ); } + + public function test_broken_link_is_rechecked_after_two_weeks(): void + { + Http::fake(['*' => Http::response()]); + Notification::fake(); + + $user = User::factory()->create(); + Link::factory()->for($user)->create([ + 'status' => Link::STATUS_BROKEN, + 'last_checked_at' => now()->subWeeks(3), + ]); + + $this->artisan('links:check --noWait'); + + $this->assertDatabaseHas('links', ['status' => Link::STATUS_OK]); + } + + public function test_broken_link_is_not_rechecked_before_two_weeks(): void + { + Http::fake(['*' => Http::response()]); + Notification::fake(); + + $user = User::factory()->create(); + Link::factory()->for($user)->create([ + 'status' => Link::STATUS_BROKEN, + 'last_checked_at' => now()->subWeek(), + ]); + + $this->artisan('links:check --noWait'); + + // Link was checked too recently — should remain broken (not re-checked) + $this->assertDatabaseHas('links', ['status' => Link::STATUS_BROKEN]); + } + + public function test_broken_link_recheck_interval_is_configurable(): void + { + Http::fake(['*' => Http::response()]); + Notification::fake(); + + config(['linkace.link_checks.broken_recheck_interval_weeks' => 4]); + + $user = User::factory()->create(); + Link::factory()->for($user)->create([ + 'status' => Link::STATUS_BROKEN, + 'last_checked_at' => now()->subWeeks(3), + ]); + + $this->artisan('links:check --noWait'); + + // 3 weeks old but interval is 4 — should NOT be re-checked yet + $this->assertDatabaseHas('links', ['status' => Link::STATUS_BROKEN]); + } } diff --git a/tests/Controller/Models/LinkControllerTest.php b/tests/Controller/Models/LinkControllerTest.php index 5c1fc04cf..a833b3843 100644 --- a/tests/Controller/Models/LinkControllerTest.php +++ b/tests/Controller/Models/LinkControllerTest.php @@ -187,7 +187,7 @@ public function test_store_with_connection_exception(): void $databaseLink = Link::first(); - $this->assertTrue($databaseLink->check_disabled); + $this->assertFalse($databaseLink->check_disabled); $this->assertEquals(Link::STATUS_BROKEN, $databaseLink->status); $this->assertEquals('bad-example.com', $databaseLink->title); } diff --git a/tests/Models/LinkCreateTest.php b/tests/Models/LinkCreateTest.php index 347d92c7d..feb05f3e3 100644 --- a/tests/Models/LinkCreateTest.php +++ b/tests/Models/LinkCreateTest.php @@ -2,10 +2,12 @@ namespace Tests\Models; +use App\Models\Link; use App\Models\User; use App\Repositories\LinkRepository; use Illuminate\Foundation\Testing\DatabaseMigrations; use Illuminate\Foundation\Testing\DatabaseTransactions; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; use Tests\TestCase; @@ -64,4 +66,23 @@ public function test_valid_link_creation(): void $this->assertDatabaseHas('links', $assertedData); } + + public function test_failed_link_creation_does_not_disable_checks(): void + { + $this->be($this->user); + + Http::fake(function () { + throw new ConnectionException('Connection refused'); + }); + + $link = LinkRepository::create([ + 'url' => 'https://unreachable.example.com/', + 'title' => null, + 'description' => null, + 'visibility' => 1, + ]); + + $this->assertEquals(Link::STATUS_BROKEN, $link->status); + $this->assertFalse($link->fresh()->check_disabled); + } } diff --git a/tests/Models/LinkUpdateTest.php b/tests/Models/LinkUpdateTest.php index 60e263bfb..d2e089c62 100644 --- a/tests/Models/LinkUpdateTest.php +++ b/tests/Models/LinkUpdateTest.php @@ -38,4 +38,57 @@ public function test_valid_link_update(): void $this->assertEquals('This is a new title!', $updatedLink->title); } + + public function test_updating_url_of_broken_link_resets_status(): void + { + $this->be($this->user); + + $link = Link::factory()->create([ + 'url' => 'https://broken-old-url.example.com', + 'status' => Link::STATUS_BROKEN, + 'last_checked_at' => now()->subDay(), + ]); + + $updatedLink = LinkRepository::update($link, [ + 'url' => 'https://working-new-url.example.com', + ]); + + $this->assertEquals(Link::STATUS_OK, $updatedLink->status); + $this->assertNull($updatedLink->last_checked_at); + } + + public function test_updating_url_of_broken_link_to_same_url_does_not_reset_status(): void + { + $this->be($this->user); + + $link = Link::factory()->create([ + 'url' => 'https://broken-url.example.com', + 'status' => Link::STATUS_BROKEN, + 'last_checked_at' => now()->subDay(), + ]); + + $updatedLink = LinkRepository::update($link, [ + 'url' => 'https://broken-url.example.com', + ]); + + $this->assertEquals(Link::STATUS_BROKEN, $updatedLink->status); + $this->assertNotNull($updatedLink->last_checked_at); + } + + public function test_updating_title_of_broken_link_does_not_reset_status(): void + { + $this->be($this->user); + + $link = Link::factory()->create([ + 'status' => Link::STATUS_BROKEN, + 'last_checked_at' => now()->subDay(), + ]); + + $updatedLink = LinkRepository::update($link, [ + 'title' => 'New title', + ]); + + $this->assertEquals(Link::STATUS_BROKEN, $updatedLink->status); + $this->assertNotNull($updatedLink->last_checked_at); + } }