From cc94ba8e55b6282650072afdcd0075ae7df06c53 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 18:09:54 +0100 Subject: [PATCH] 89 - Add article cleanup job with 30-day retention policy --- app/Jobs/CleanupArticlesJob.php | 25 +++++ routes/console.php | 7 ++ tests/Unit/Jobs/CleanupArticlesJobTest.php | 111 +++++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 app/Jobs/CleanupArticlesJob.php create mode 100644 tests/Unit/Jobs/CleanupArticlesJobTest.php diff --git a/app/Jobs/CleanupArticlesJob.php b/app/Jobs/CleanupArticlesJob.php new file mode 100644 index 0000000..0799064 --- /dev/null +++ b/app/Jobs/CleanupArticlesJob.php @@ -0,0 +1,25 @@ +subDays(self::RETENTION_DAYS)) + ->whereDoesntHave('routeArticles', fn ($q) => $q->whereIn('approval_status', [ + ApprovalStatusEnum::PENDING, + ApprovalStatusEnum::APPROVED, + ])) + ->delete(); + } +} diff --git a/routes/console.php b/routes/console.php index 98aac72..31b4712 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,6 +2,7 @@ use App\Jobs\ArticleDiscoveryJob; use App\Jobs\CheckFeedStalenessJob; +use App\Jobs\CleanupArticlesJob; use App\Jobs\PublishNextArticleJob; use App\Jobs\SyncChannelPostsJob; use Illuminate\Support\Facades\Schedule; @@ -27,3 +28,9 @@ ->name('check-feed-staleness') ->withoutOverlapping() ->onOneServer(); + +Schedule::job(new CleanupArticlesJob) + ->daily() + ->name('cleanup-old-articles') + ->withoutOverlapping() + ->onOneServer(); diff --git a/tests/Unit/Jobs/CleanupArticlesJobTest.php b/tests/Unit/Jobs/CleanupArticlesJobTest.php new file mode 100644 index 0000000..8e58f0d --- /dev/null +++ b/tests/Unit/Jobs/CleanupArticlesJobTest.php @@ -0,0 +1,111 @@ +for(Feed::factory())->create([ + 'created_at' => now()->subDays(31), + ]); + $recent = Article::factory()->for(Feed::factory())->create([ + 'created_at' => now()->subDays(10), + ]); + + (new CleanupArticlesJob)->handle(); + + $this->assertDatabaseMissing('articles', ['id' => $old->id]); + $this->assertDatabaseHas('articles', ['id' => $recent->id]); + } + + public function test_preserves_old_articles_with_pending_route_articles(): void + { + $route = Route::factory()->create(); + $article = Article::factory()->for($route->feed)->create([ + 'created_at' => now()->subDays(31), + ]); + RouteArticle::factory()->for($article)->create([ + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + 'approval_status' => ApprovalStatusEnum::PENDING, + ]); + + (new CleanupArticlesJob)->handle(); + + $this->assertDatabaseHas('articles', ['id' => $article->id]); + } + + public function test_preserves_old_articles_with_approved_route_articles(): void + { + $route = Route::factory()->create(); + $article = Article::factory()->for($route->feed)->create([ + 'created_at' => now()->subDays(31), + ]); + RouteArticle::factory()->for($article)->create([ + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + 'approval_status' => ApprovalStatusEnum::APPROVED, + ]); + + (new CleanupArticlesJob)->handle(); + + $this->assertDatabaseHas('articles', ['id' => $article->id]); + } + + public function test_deletes_old_articles_with_only_rejected_route_articles(): void + { + $route = Route::factory()->create(); + $article = Article::factory()->for($route->feed)->create([ + 'created_at' => now()->subDays(31), + ]); + RouteArticle::factory()->for($article)->create([ + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + 'approval_status' => ApprovalStatusEnum::REJECTED, + ]); + + (new CleanupArticlesJob)->handle(); + + $this->assertDatabaseMissing('articles', ['id' => $article->id]); + } + + public function test_cascade_deletes_route_articles(): void + { + $route = Route::factory()->create(); + $article = Article::factory()->for($route->feed)->create([ + 'created_at' => now()->subDays(31), + ]); + $routeArticle = RouteArticle::factory()->for($article)->create([ + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + 'approval_status' => ApprovalStatusEnum::REJECTED, + ]); + + (new CleanupArticlesJob)->handle(); + + $this->assertDatabaseMissing('route_articles', ['id' => $routeArticle->id]); + } + + public function test_preserves_article_at_exact_retention_boundary(): void + { + $boundary = Article::factory()->for(Feed::factory())->create([ + 'created_at' => now()->subDays(30), + ]); + + (new CleanupArticlesJob)->handle(); + + $this->assertDatabaseHas('articles', ['id' => $boundary->id]); + } +}