From fbcf4faa9f2a7ad5fd650d7af4d24ffd3e9abb70 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Mon, 10 Aug 2026 20:46:07 +0200 Subject: [PATCH] 91 - Prune activity logs after 90 days --- .gitignore | 1 + app/Jobs/CleanupActivityLogsJob.php | 19 +++ routes/console.php | 7 + tests/Feature/ActivityRecordingTest.php | 125 ++++++++++++++++++ ...ticleDiscoveryForFeedJobEmptyFetchTest.php | 29 ++++ tests/Unit/Models/ActivityLogTest.php | 73 ++++++++++ tests/Unit/Services/ActivitySummaryTest.php | 57 ++++++++ 7 files changed, 311 insertions(+) create mode 100644 app/Jobs/CleanupActivityLogsJob.php create mode 100644 tests/Feature/ActivityRecordingTest.php create mode 100644 tests/Unit/Models/ActivityLogTest.php create mode 100644 tests/Unit/Services/ActivitySummaryTest.php diff --git a/.gitignore b/.gitignore index a6e08b96..02e7e7d8 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ yarn-error.log /coverage.xml /.php-cs-fixer.dist.php /.php-cs-fixer.cache +.aider* diff --git a/app/Jobs/CleanupActivityLogsJob.php b/app/Jobs/CleanupActivityLogsJob.php new file mode 100644 index 00000000..a0b40dd7 --- /dev/null +++ b/app/Jobs/CleanupActivityLogsJob.php @@ -0,0 +1,19 @@ +subDays(self::RETENTION_DAYS))->delete(); + } +} diff --git a/routes/console.php b/routes/console.php index 31b4712b..98068230 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,6 +2,7 @@ use App\Jobs\ArticleDiscoveryJob; use App\Jobs\CheckFeedStalenessJob; +use App\Jobs\CleanupActivityLogsJob; use App\Jobs\CleanupArticlesJob; use App\Jobs\PublishNextArticleJob; use App\Jobs\SyncChannelPostsJob; @@ -34,3 +35,9 @@ ->name('cleanup-old-articles') ->withoutOverlapping() ->onOneServer(); + +Schedule::job(new CleanupActivityLogsJob) + ->daily() + ->name('cleanup-activity-logs') + ->withoutOverlapping() + ->onOneServer(); diff --git a/tests/Feature/ActivityRecordingTest.php b/tests/Feature/ActivityRecordingTest.php new file mode 100644 index 00000000..54c65a53 --- /dev/null +++ b/tests/Feature/ActivityRecordingTest.php @@ -0,0 +1,125 @@ +create(); + /** @var Route $route */ + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + $article = Article::factory()->create(['feed_id' => $feed->id, 'title' => 'A Headline']); + + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->forRoute($route)->create([ + 'article_id' => $article->id, + 'approval_status' => ApprovalStatusEnum::PENDING, + 'validated_at' => now(), + ]); + + return $routeArticle; + } + + public function test_approving_records_activity(): void + { + Event::fake([RouteArticleApproved::class]); + + $this->pendingRouteArticle()->approve(); + + $log = ActivityLog::ofType(ActivityTypeEnum::APPROVE)->first(); + + $this->assertNotNull($log); + $this->assertStringContainsString('A Headline', $log->message); + $this->assertSame(Article::class, $log->subject_type); + } + + public function test_rejecting_records_activity(): void + { + $this->pendingRouteArticle()->reject(); + + $log = ActivityLog::ofType(ActivityTypeEnum::REJECT)->first(); + + $this->assertNotNull($log); + $this->assertStringContainsString('A Headline', $log->message); + } + + public function test_approving_an_already_approved_article_records_nothing(): void + { + Event::fake([RouteArticleApproved::class]); + + $routeArticle = $this->pendingRouteArticle(); + $routeArticle->approve(); + $routeArticle->approve(); + + $this->assertSame(1, ActivityLog::ofType(ActivityTypeEnum::APPROVE)->count()); + } + + public function test_rejecting_an_already_rejected_article_records_nothing(): void + { + $routeArticle = $this->pendingRouteArticle(); + $routeArticle->reject(); + $routeArticle->reject(); + + $this->assertSame(1, ActivityLog::ofType(ActivityTypeEnum::REJECT)->count()); + } + + public function test_clearing_pending_articles_records_one_summary_entry(): void + { + $routeArticle = $this->pendingRouteArticle(); + $this->pendingRouteArticle(); + + Livewire::test(Articles::class)->call('clear'); + + $entries = ActivityLog::ofType(ActivityTypeEnum::REJECT)->get(); + + $this->assertCount(1, $entries); + $this->assertStringContainsString('2 pending articles', $entries->first()->message); + $this->assertSame(ApprovalStatusEnum::REJECTED, $routeArticle->fresh()->approval_status); + } + + public function test_clearing_nothing_records_no_entry(): void + { + Livewire::test(Articles::class)->call('clear'); + + $this->assertSame(0, ActivityLog::count()); + } + + public function test_one_event_records_exactly_one_entry(): void + { + ActivityLogged::dispatch(ActivityTypeEnum::FETCH, 'Single entry'); + + $this->assertSame(1, ActivityLog::where('message', 'Single entry')->count()); + } + + public function test_a_long_message_is_truncated_to_fit_the_column(): void + { + ActivityLogged::dispatch(ActivityTypeEnum::PUBLISH, str_repeat('a', 400)); + + $this->assertSame(255, strlen(ActivityLog::first()->message)); + } + + public function test_an_empty_context_is_stored_as_null(): void + { + ActivityLogged::dispatch(ActivityTypeEnum::FETCH, 'No context'); + + $this->assertNull(ActivityLog::first()->context); + } +} diff --git a/tests/Feature/Jobs/ArticleDiscoveryForFeedJobEmptyFetchTest.php b/tests/Feature/Jobs/ArticleDiscoveryForFeedJobEmptyFetchTest.php index 516c8ab6..6f697573 100644 --- a/tests/Feature/Jobs/ArticleDiscoveryForFeedJobEmptyFetchTest.php +++ b/tests/Feature/Jobs/ArticleDiscoveryForFeedJobEmptyFetchTest.php @@ -2,9 +2,11 @@ namespace Tests\Feature\Jobs; +use App\Enums\ActivityTypeEnum; use App\Enums\NotificationSeverityEnum; use App\Enums\NotificationTypeEnum; use App\Jobs\ArticleDiscoveryForFeedJob; +use App\Models\ActivityLog; use App\Models\Article; use App\Models\Feed; use App\Models\Notification; @@ -119,6 +121,33 @@ public function test_creates_new_notification_when_previous_is_read(): void $this->assertDatabaseCount('notifications', 2); } + public function test_empty_fetch_records_error_activity_not_fetch(): void + { + $feed = Feed::factory()->create(['is_active' => true, 'name' => 'Belga']); + + $this->runJobForFeed($feed); + + $entry = ActivityLog::first(); + + $this->assertSame(ActivityTypeEnum::ERROR, $entry->type); + $this->assertStringContainsString('Belga', $entry->message); + $this->assertSame(0, ActivityLog::ofType(ActivityTypeEnum::FETCH)->count()); + } + + public function test_fetch_with_articles_records_fetch_activity(): void + { + $feed = Feed::factory()->create(['is_active' => true, 'name' => 'VRT']); + $articles = Article::factory()->count(2)->create(['feed_id' => $feed->id]); + + $this->runJobForFeed($feed, $articles); + + $entry = ActivityLog::first(); + + $this->assertSame(ActivityTypeEnum::FETCH, $entry->type); + $this->assertStringContainsString('2 articles', $entry->message); + $this->assertSame($feed->getMorphClass(), $entry->subject_type); + } + public function test_notification_for_one_feed_does_not_suppress_another(): void { $notified = Feed::factory()->create(['is_active' => true]); diff --git a/tests/Unit/Models/ActivityLogTest.php b/tests/Unit/Models/ActivityLogTest.php new file mode 100644 index 00000000..0f98eb45 --- /dev/null +++ b/tests/Unit/Models/ActivityLogTest.php @@ -0,0 +1,73 @@ +type(ActivityTypeEnum::PUBLISH)->create(); + ActivityLog::factory()->type(ActivityTypeEnum::FETCH)->create(); + + $results = ActivityLog::ofType(ActivityTypeEnum::PUBLISH)->get(); + + $this->assertCount(1, $results); + $this->assertSame(ActivityTypeEnum::PUBLISH, $results->first()->type); + } + + public function test_since_excludes_entries_before_the_window(): void + { + ActivityLog::factory()->loggedAt(now()->subHours(2))->create(['message' => 'inside']); + ActivityLog::factory()->loggedAt(now()->subDays(3))->create(['message' => 'outside']); + + $results = ActivityLog::since(now()->subDay())->get(); + + $this->assertCount(1, $results); + $this->assertSame('inside', $results->first()->message); + } + + public function test_latest_first_orders_newest_before_oldest(): void + { + ActivityLog::factory()->loggedAt(now()->subDays(2))->create(['message' => 'older']); + ActivityLog::factory()->loggedAt(now())->create(['message' => 'newer']); + + $messages = ActivityLog::latestFirst()->pluck('message')->all(); + + $this->assertSame(['newer', 'older'], $messages); + } + + public function test_latest_first_breaks_ties_on_id(): void + { + $sameMoment = now(); + $first = ActivityLog::factory()->loggedAt($sameMoment)->create(); + $second = ActivityLog::factory()->loggedAt($sameMoment)->create(); + + $ids = ActivityLog::latestFirst()->pluck('id')->all(); + + $this->assertSame([$second->id, $first->id], $ids); + } + + public function test_subject_resolves_to_the_related_model(): void + { + $article = Article::factory()->create(); + $log = ActivityLog::factory()->for_subject($article)->create(); + + $this->assertInstanceOf(Article::class, $log->subject); + $this->assertSame($article->id, $log->subject->id); + } + + public function test_context_round_trips_as_an_array(): void + { + $log = ActivityLog::factory()->create(['context' => ['reason' => 'no content', 'attempt' => 2]]); + + $this->assertSame(['reason' => 'no content', 'attempt' => 2], $log->fresh()->context); + } +} diff --git a/tests/Unit/Services/ActivitySummaryTest.php b/tests/Unit/Services/ActivitySummaryTest.php new file mode 100644 index 00000000..c1cfec78 --- /dev/null +++ b/tests/Unit/Services/ActivitySummaryTest.php @@ -0,0 +1,57 @@ +count(3)->type(ActivityTypeEnum::FETCH)->create(); + ActivityLog::factory()->count(2)->type(ActivityTypeEnum::PUBLISH)->create(); + + $summary = (new ActivitySummary)->since(now()->subDay()); + + $this->assertSame(3, $summary[ActivityTypeEnum::FETCH->value]); + $this->assertSame(2, $summary[ActivityTypeEnum::PUBLISH->value]); + } + + public function test_every_type_is_present_even_with_no_rows(): void + { + $summary = (new ActivitySummary)->since(now()->subDay()); + + foreach (ActivityTypeEnum::cases() as $case) { + $this->assertArrayHasKey($case->value, $summary); + $this->assertSame(0, $summary[$case->value]); + } + } + + public function test_entries_outside_the_window_are_not_counted(): void + { + ActivityLog::factory()->type(ActivityTypeEnum::FETCH)->loggedAt(now()->subHours(2))->create(); + ActivityLog::factory()->type(ActivityTypeEnum::FETCH)->loggedAt(now()->subDays(5))->create(); + + $summary = (new ActivitySummary)->since(now()->subDay()); + + $this->assertSame(1, $summary[ActivityTypeEnum::FETCH->value]); + } + + public function test_uses_a_single_query(): void + { + ActivityLog::factory()->count(5)->create(); + + \DB::enableQueryLog(); + (new ActivitySummary)->since(now()->subDay()); + $queries = \DB::getQueryLog(); + \DB::disableQueryLog(); + + $this->assertCount(1, $queries); + } +}