Release v1.4.0 #146

Merged
myrmidex merged 71 commits from release/v1.4.0 into main 2026-08-15 00:36:54 +02:00
7 changed files with 311 additions and 0 deletions
Showing only changes of commit fbcf4faa9f - Show all commits

1
.gitignore vendored
View file

@ -25,3 +25,4 @@ yarn-error.log
/coverage.xml /coverage.xml
/.php-cs-fixer.dist.php /.php-cs-fixer.dist.php
/.php-cs-fixer.cache /.php-cs-fixer.cache
.aider*

View 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();
}
}

View file

@ -2,6 +2,7 @@
use App\Jobs\ArticleDiscoveryJob; use App\Jobs\ArticleDiscoveryJob;
use App\Jobs\CheckFeedStalenessJob; use App\Jobs\CheckFeedStalenessJob;
use App\Jobs\CleanupActivityLogsJob;
use App\Jobs\CleanupArticlesJob; use App\Jobs\CleanupArticlesJob;
use App\Jobs\PublishNextArticleJob; use App\Jobs\PublishNextArticleJob;
use App\Jobs\SyncChannelPostsJob; use App\Jobs\SyncChannelPostsJob;
@ -34,3 +35,9 @@
->name('cleanup-old-articles') ->name('cleanup-old-articles')
->withoutOverlapping() ->withoutOverlapping()
->onOneServer(); ->onOneServer();
Schedule::job(new CleanupActivityLogsJob)
->daily()
->name('cleanup-activity-logs')
->withoutOverlapping()
->onOneServer();

View 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);
}
}

View file

@ -2,9 +2,11 @@
namespace Tests\Feature\Jobs; namespace Tests\Feature\Jobs;
use App\Enums\ActivityTypeEnum;
use App\Enums\NotificationSeverityEnum; use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum; use App\Enums\NotificationTypeEnum;
use App\Jobs\ArticleDiscoveryForFeedJob; use App\Jobs\ArticleDiscoveryForFeedJob;
use App\Models\ActivityLog;
use App\Models\Article; use App\Models\Article;
use App\Models\Feed; use App\Models\Feed;
use App\Models\Notification; use App\Models\Notification;
@ -119,6 +121,33 @@ public function test_creates_new_notification_when_previous_is_read(): void
$this->assertDatabaseCount('notifications', 2); $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 public function test_notification_for_one_feed_does_not_suppress_another(): void
{ {
$notified = Feed::factory()->create(['is_active' => true]); $notified = Feed::factory()->create(['is_active' => true]);

View 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);
}
}

View 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);
}
}