91 - Prune activity logs after 90 days
This commit is contained in:
parent
c986f000f6
commit
fbcf4faa9f
7 changed files with 311 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -25,3 +25,4 @@ yarn-error.log
|
|||
/coverage.xml
|
||||
/.php-cs-fixer.dist.php
|
||||
/.php-cs-fixer.cache
|
||||
.aider*
|
||||
|
|
|
|||
19
app/Jobs/CleanupActivityLogsJob.php
Normal file
19
app/Jobs/CleanupActivityLogsJob.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\ActivityLog;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class CleanupActivityLogsJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
private const RETENTION_DAYS = 90;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
ActivityLog::where('logged_at', '<', now()->subDays(self::RETENTION_DAYS))->delete();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
125
tests/Feature/ActivityRecordingTest.php
Normal file
125
tests/Feature/ActivityRecordingTest.php
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\ActivityTypeEnum;
|
||||
use App\Enums\ApprovalStatusEnum;
|
||||
use App\Events\ActivityLogged;
|
||||
use App\Events\RouteArticleApproved;
|
||||
use App\Livewire\Articles;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Article;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Route;
|
||||
use App\Models\RouteArticle;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ActivityRecordingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function pendingRouteArticle(): RouteArticle
|
||||
{
|
||||
$feed = Feed::factory()->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
73
tests/Unit/Models/ActivityLogTest.php
Normal file
73
tests/Unit/Models/ActivityLogTest.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Models;
|
||||
|
||||
use App\Enums\ActivityTypeEnum;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Article;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ActivityLogTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_of_type_returns_only_that_type(): void
|
||||
{
|
||||
ActivityLog::factory()->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);
|
||||
}
|
||||
}
|
||||
57
tests/Unit/Services/ActivitySummaryTest.php
Normal file
57
tests/Unit/Services/ActivitySummaryTest.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Enums\ActivityTypeEnum;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Services\Activity\ActivitySummary;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ActivitySummaryTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_counts_are_grouped_per_type(): void
|
||||
{
|
||||
ActivityLog::factory()->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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue