119 - Publish articles from stored data instead of re-fetching
This commit is contained in:
parent
433aa79007
commit
ecb12ddfc7
8 changed files with 230 additions and 11 deletions
|
|
@ -8,6 +8,7 @@
|
|||
use App\Enums\PublishStatusEnum;
|
||||
use App\Events\ActionPerformed;
|
||||
use App\Exceptions\PublishException;
|
||||
use App\Models\Article;
|
||||
use App\Models\RouteArticle;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Notification\NotificationService;
|
||||
|
|
@ -33,7 +34,7 @@ public function execute(RouteArticle $routeArticle): PublishOutcome
|
|||
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]);
|
||||
|
||||
try {
|
||||
$extractedData = $this->articleFetcher->fetchArticleData($article);
|
||||
$extractedData = $this->resolvePublishData($article);
|
||||
$outcome = $this->publishingService->publishRouteArticle($routeArticle, $extractedData);
|
||||
} catch (Exception $e) {
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
|
||||
|
|
@ -63,6 +64,22 @@ public function execute(RouteArticle $routeArticle): PublishOutcome
|
|||
return $outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function resolvePublishData(Article $article): array
|
||||
{
|
||||
if (empty($article->description) && empty($article->image_url)) {
|
||||
return $this->articleFetcher->fetchArticleData($article);
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => $article->title,
|
||||
'description' => $article->description,
|
||||
'thumbnail' => $article->image_url,
|
||||
];
|
||||
}
|
||||
|
||||
private function recordPublished(RouteArticle $routeArticle): void
|
||||
{
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]);
|
||||
|
|
|
|||
|
|
@ -24,9 +24,22 @@ public function definition(): array
|
|||
'title' => $this->faker->sentence(),
|
||||
'description' => $this->faker->paragraph(),
|
||||
'content' => $this->faker->paragraphs(3, true),
|
||||
'image_url' => $this->faker->optional()->imageUrl(),
|
||||
'image_url' => $this->faker->imageUrl(),
|
||||
'published_at' => $this->faker->optional()->dateTimeBetween('-1 month', 'now'),
|
||||
'author' => $this->faker->optional()->name(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* An article discovered but never validated, so publishing must fetch its data live.
|
||||
*/
|
||||
public function unvalidated(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'description' => null,
|
||||
'content' => null,
|
||||
'image_url' => null,
|
||||
'validated_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ private function makeListener(): PublishApprovedArticleListener
|
|||
$service->shouldReceive('makePublisher')->andReturn($publisher);
|
||||
|
||||
$fetcher = Mockery::mock(ArticleFetcher::class);
|
||||
$fetcher->shouldReceive('fetchArticleData')->andReturn(['title' => 'Test Article']);
|
||||
$fetcher->shouldNotReceive('fetchArticleData');
|
||||
|
||||
return new PublishApprovedArticleListener(new PublishRouteArticleAction($fetcher, $service, new NotificationService));
|
||||
}
|
||||
|
|
@ -160,7 +160,7 @@ public function test_two_queued_listeners_create_only_one_remote_post(): void
|
|||
$service->shouldReceive('makePublisher')->andReturn($publisher);
|
||||
|
||||
$fetcher = Mockery::mock(ArticleFetcher::class);
|
||||
$fetcher->shouldReceive('fetchArticleData')->andReturn(['title' => 'Test Article']);
|
||||
$fetcher->shouldNotReceive('fetchArticleData');
|
||||
|
||||
$listener = new PublishApprovedArticleListener(new PublishRouteArticleAction($fetcher, $service, new NotificationService));
|
||||
$listener->handle(new RouteArticleApproved($routeArticle));
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ private function createApprovedRouteArticle(string $title = 'Test Article'): Rou
|
|||
$feed = Feed::factory()->create();
|
||||
/** @var Route $route */
|
||||
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
$article = Article::factory()->create([
|
||||
$article = Article::factory()->unvalidated()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'title' => $title,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -342,12 +342,16 @@ public function test_expanding_a_feed_shows_all_its_articles_beyond_one_page():
|
|||
{
|
||||
$vrt = Feed::factory()->create(['name' => 'VRT News']);
|
||||
|
||||
$base = now()->startOfSecond();
|
||||
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
$this->travelTo(now()->addMinutes($i), function () use ($vrt, $i) {
|
||||
$this->travelTo($base->copy()->addMinutes($i), function () use ($vrt, $i) {
|
||||
$this->createRouteArticleForFeed($vrt, "VRT Article {$i}");
|
||||
});
|
||||
}
|
||||
|
||||
$this->travelBack();
|
||||
|
||||
$component = Livewire::test(Articles::class)->call('toggleFeed', $vrt->id);
|
||||
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ protected function setUp(): void
|
|||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'url' => 'https://news.test/already-posted',
|
||||
'title' => 'Already Posted',
|
||||
]);
|
||||
|
||||
/** @var RouteArticle $routeArticle */
|
||||
|
|
@ -124,7 +125,7 @@ public function test_a_skipped_duplicate_is_not_reported_as_a_publish_failure():
|
|||
PlatformChannelPost::storePost($channel, '555', $article->url, 'Already Posted');
|
||||
|
||||
$fetcher = Mockery::mock(ArticleFetcher::class);
|
||||
$fetcher->shouldReceive('fetchArticleData')->andReturn(['title' => 'Already Posted']);
|
||||
$fetcher->shouldNotReceive('fetchArticleData');
|
||||
|
||||
$publisher = Mockery::mock(LemmyPublisher::class);
|
||||
$publisher->shouldNotReceive('publishToChannel');
|
||||
|
|
@ -141,7 +142,7 @@ public function test_a_genuine_failure_is_still_reported(): void
|
|||
[$routeArticle] = $this->fixture;
|
||||
|
||||
$fetcher = Mockery::mock(ArticleFetcher::class);
|
||||
$fetcher->shouldReceive('fetchArticleData')->andReturn(['title' => 'Some Title']);
|
||||
$fetcher->shouldNotReceive('fetchArticleData');
|
||||
|
||||
$publisher = Mockery::mock(LemmyPublisher::class);
|
||||
$publisher->shouldReceive('publishToChannel')->andThrow(new \RuntimeException('Lemmy rejected the post'));
|
||||
|
|
|
|||
184
tests/Unit/Actions/PublishRouteArticleActionTest.php
Normal file
184
tests/Unit/Actions/PublishRouteArticleActionTest.php
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Actions;
|
||||
|
||||
use App\Actions\PublishRouteArticleAction;
|
||||
use App\Enums\PublishStatusEnum;
|
||||
use App\Models\Article;
|
||||
use App\Models\ArticlePublication;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Route;
|
||||
use App\Models\RouteArticle;
|
||||
use App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use App\Services\Publishing\ArticlePublishingService;
|
||||
use App\Services\Publishing\PublishOutcome;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PublishRouteArticleActionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $articleAttributes
|
||||
*/
|
||||
private function createRouteArticle(array $articleAttributes, bool $unvalidated = false): RouteArticle
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
/** @var Route $route */
|
||||
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
|
||||
$factory = Article::factory();
|
||||
if ($unvalidated) {
|
||||
$factory = $factory->unvalidated();
|
||||
}
|
||||
$article = $factory->create(array_merge(['feed_id' => $feed->id], $articleAttributes));
|
||||
|
||||
/** @var RouteArticle $routeArticle */
|
||||
$routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([
|
||||
'article_id' => $article->id,
|
||||
]);
|
||||
|
||||
return $routeArticle;
|
||||
}
|
||||
|
||||
private function makePublication(): ArticlePublication
|
||||
{
|
||||
/** @var ArticlePublication $publication */
|
||||
$publication = ArticlePublication::factory()->create();
|
||||
|
||||
return $publication;
|
||||
}
|
||||
|
||||
public function test_publish_uses_stored_article_data_without_fetching(): void
|
||||
{
|
||||
$routeArticle = $this->createRouteArticle([
|
||||
'title' => 'Stored Title',
|
||||
'description' => 'Stored description',
|
||||
'image_url' => 'https://cdn.test/stored.jpg',
|
||||
]);
|
||||
|
||||
$fetcher = Mockery::mock(ArticleFetcher::class);
|
||||
$fetcher->shouldNotReceive('fetchArticleData');
|
||||
|
||||
$publishingService = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingService->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->with(Mockery::any(), [
|
||||
'title' => 'Stored Title',
|
||||
'description' => 'Stored description',
|
||||
'thumbnail' => 'https://cdn.test/stored.jpg',
|
||||
])
|
||||
->andReturn(PublishOutcome::published($this->makePublication()));
|
||||
|
||||
(new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService))
|
||||
->execute($routeArticle);
|
||||
|
||||
$this->assertSame(PublishStatusEnum::PUBLISHED, $routeArticle->fresh()->publish_status);
|
||||
}
|
||||
|
||||
public function test_publish_body_comes_from_description_not_content(): void
|
||||
{
|
||||
$routeArticle = $this->createRouteArticle([
|
||||
'description' => 'The short description',
|
||||
'content' => 'The very much longer full article body text',
|
||||
]);
|
||||
|
||||
$fetcher = Mockery::mock(ArticleFetcher::class);
|
||||
$fetcher->shouldNotReceive('fetchArticleData');
|
||||
|
||||
$captured = null;
|
||||
$publishingService = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingService->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->andReturnUsing(function ($ra, $data) use (&$captured) {
|
||||
$captured = $data;
|
||||
|
||||
return PublishOutcome::published($this->makePublication());
|
||||
});
|
||||
|
||||
(new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService))
|
||||
->execute($routeArticle);
|
||||
|
||||
$this->assertSame('The short description', $captured['description']);
|
||||
$this->assertArrayNotHasKey('content', $captured);
|
||||
}
|
||||
|
||||
public function test_publish_falls_back_to_fetching_when_description_is_blank(): void
|
||||
{
|
||||
$routeArticle = $this->createRouteArticle([
|
||||
'description' => '',
|
||||
'image_url' => null,
|
||||
]);
|
||||
|
||||
$fetched = ['title' => 'Fetched Title', 'description' => 'Fetched description'];
|
||||
|
||||
$fetcher = Mockery::mock(ArticleFetcher::class);
|
||||
$fetcher->shouldReceive('fetchArticleData')->once()->andReturn($fetched);
|
||||
|
||||
$publishingService = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingService->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->with(Mockery::any(), $fetched)
|
||||
->andReturn(PublishOutcome::published($this->makePublication()));
|
||||
|
||||
(new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService))
|
||||
->execute($routeArticle);
|
||||
|
||||
$this->assertSame(PublishStatusEnum::PUBLISHED, $routeArticle->fresh()->publish_status);
|
||||
}
|
||||
|
||||
public function test_publish_falls_back_to_fetching_when_article_has_no_stored_data(): void
|
||||
{
|
||||
$routeArticle = $this->createRouteArticle([], unvalidated: true);
|
||||
|
||||
$fetched = [
|
||||
'title' => 'Fetched Title',
|
||||
'description' => 'Fetched description',
|
||||
'thumbnail' => 'https://cdn.test/fetched.jpg',
|
||||
];
|
||||
|
||||
$fetcher = Mockery::mock(ArticleFetcher::class);
|
||||
$fetcher->shouldReceive('fetchArticleData')->once()->andReturn($fetched);
|
||||
|
||||
$publishingService = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingService->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->with(Mockery::any(), $fetched)
|
||||
->andReturn(PublishOutcome::published($this->makePublication()));
|
||||
|
||||
(new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService))
|
||||
->execute($routeArticle);
|
||||
|
||||
$this->assertSame(PublishStatusEnum::PUBLISHED, $routeArticle->fresh()->publish_status);
|
||||
}
|
||||
|
||||
public function test_publish_does_not_refetch_for_articles_stored_before_image_url_existed(): void
|
||||
{
|
||||
$routeArticle = $this->createRouteArticle([
|
||||
'title' => 'Legacy Title',
|
||||
'description' => 'Legacy description',
|
||||
'image_url' => null,
|
||||
]);
|
||||
|
||||
$fetcher = Mockery::mock(ArticleFetcher::class);
|
||||
$fetcher->shouldNotReceive('fetchArticleData');
|
||||
|
||||
$publishingService = Mockery::mock(ArticlePublishingService::class);
|
||||
$publishingService->shouldReceive('publishRouteArticle')
|
||||
->once()
|
||||
->with(Mockery::any(), [
|
||||
'title' => 'Legacy Title',
|
||||
'description' => 'Legacy description',
|
||||
'thumbnail' => null,
|
||||
])
|
||||
->andReturn(PublishOutcome::published($this->makePublication()));
|
||||
|
||||
(new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService))
|
||||
->execute($routeArticle);
|
||||
|
||||
$this->assertSame(PublishStatusEnum::PUBLISHED, $routeArticle->fresh()->publish_status);
|
||||
}
|
||||
}
|
||||
|
|
@ -46,7 +46,7 @@ private function createApprovedRouteArticle(array $articleOverrides = [], array
|
|||
$feed = Feed::factory()->create();
|
||||
/** @var Route $route */
|
||||
$route = Route::factory()->active()->create(array_merge(['feed_id' => $feed->id], $routeOverrides));
|
||||
$article = Article::factory()->create(array_merge(['feed_id' => $feed->id], $articleOverrides));
|
||||
$article = Article::factory()->unvalidated()->create(array_merge(['feed_id' => $feed->id], $articleOverrides));
|
||||
|
||||
/** @var RouteArticle $routeArticle */
|
||||
$routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([
|
||||
|
|
@ -145,8 +145,8 @@ public function test_handle_publishes_oldest_approved_route_article(): void
|
|||
/** @var Route $route */
|
||||
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
|
||||
$olderArticle = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
$newerArticle = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
$olderArticle = Article::factory()->unvalidated()->create(['feed_id' => $feed->id]);
|
||||
$newerArticle = Article::factory()->unvalidated()->create(['feed_id' => $feed->id]);
|
||||
|
||||
RouteArticle::factory()->forRoute($route)->approved()->create([
|
||||
'article_id' => $olderArticle->id,
|
||||
|
|
|
|||
Loading…
Reference in a new issue