$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_fails_when_fallback_fetch_returns_nothing(): void { $routeArticle = $this->createRouteArticle(['title' => 'Unreachable Article'], unvalidated: true); $fetcher = Mockery::mock(ArticleFetcher::class); $fetcher->shouldReceive('fetchArticleData')->once()->andReturn([]); $publishingService = Mockery::mock(ArticlePublishingService::class); $publishingService->shouldNotReceive('publishRouteArticle'); $outcome = (new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService)) ->execute($routeArticle); $this->assertTrue($outcome->failed()); $this->assertSame(PublishStatusEnum::ERROR, $routeArticle->fresh()->publish_status); $this->assertDatabaseCount('article_publications', 0); $this->assertDatabaseHas('notifications', [ 'type' => NotificationTypeEnum::PUBLISH_FAILED->value, ]); } public function test_repeated_failures_exhaust_the_retry_attempts(): void { $routeArticle = $this->createRouteArticle([], unvalidated: true); $fetcher = Mockery::mock(ArticleFetcher::class); $fetcher->shouldReceive('fetchArticleData')->andReturn([]); $publishingService = Mockery::mock(ArticlePublishingService::class); $action = new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService); for ($i = 0; $i < RouteArticle::MAX_PUBLISH_ATTEMPTS; $i++) { $action->execute($routeArticle); $routeArticle->refresh(); } $this->assertSame(RouteArticle::MAX_PUBLISH_ATTEMPTS, $routeArticle->publish_attempts); $this->assertTrue($routeArticle->hasExhaustedPublishAttempts()); } public function test_a_successful_publish_resets_earlier_failed_attempts(): void { $routeArticle = $this->createRouteArticle([ 'description' => 'Stored description', ]); $routeArticle->update(['publish_attempts' => 2, 'next_attempt_at' => now()->subMinute()]); $fetcher = Mockery::mock(ArticleFetcher::class); $fetcher->shouldNotReceive('fetchArticleData'); $publishingService = Mockery::mock(ArticlePublishingService::class); $publishingService->shouldReceive('publishRouteArticle') ->once() ->andReturn(PublishOutcome::published($this->makePublication())); (new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService)) ->execute($routeArticle); $routeArticle->refresh(); $this->assertSame(0, $routeArticle->publish_attempts); $this->assertNull($routeArticle->next_attempt_at); } public function test_publish_fails_when_fallback_recovers_only_a_title(): void { $routeArticle = $this->createRouteArticle([], unvalidated: true); $fetcher = Mockery::mock(ArticleFetcher::class); $fetcher->shouldReceive('fetchArticleData')->once()->andReturn(['title' => 'Recovered Title']); $publishingService = Mockery::mock(ArticlePublishingService::class); $publishingService->shouldNotReceive('publishRouteArticle'); $outcome = (new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService)) ->execute($routeArticle); $this->assertTrue($outcome->failed()); $this->assertSame(PublishStatusEnum::ERROR, $routeArticle->fresh()->publish_status); } public function test_publish_succeeds_when_fallback_returns_a_description_without_a_thumbnail(): void { $routeArticle = $this->createRouteArticle([], unvalidated: true); $fetched = ['title' => 'Recovered Title', 'description' => 'Recovered 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_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); } }