$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(FetchArticleDataAction::class); $fetcher->shouldNotReceive('execute'); $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(FetchArticleDataAction::class); $fetcher->shouldNotReceive('execute'); $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(FetchArticleDataAction::class); $fetcher->shouldReceive('execute')->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(FetchArticleDataAction::class); $fetcher->shouldReceive('execute')->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(FetchArticleDataAction::class); $fetcher->shouldReceive('execute')->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_a_failure_stores_the_reason_and_stops_further_attempts(): void { $routeArticle = $this->createRouteArticle([], unvalidated: true); $fetcher = Mockery::mock(FetchArticleDataAction::class); $fetcher->shouldReceive('execute')->andReturn([]); $publishingService = Mockery::mock(ArticlePublishingService::class); (new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService)) ->execute($routeArticle); $routeArticle->refresh(); $this->assertSame(PublishStatusEnum::ERROR, $routeArticle->publish_status); $this->assertNotNull($routeArticle->publish_error); $this->assertFalse( RouteArticle::query()->dueForPublishing()->whereKey($routeArticle->getKey())->exists() ); } public function test_a_successful_publish_clears_an_earlier_failure(): void { $routeArticle = $this->createRouteArticle([ 'description' => 'Stored description', ]); $routeArticle->recordPublishFailed('an earlier failure'); $fetcher = Mockery::mock(FetchArticleDataAction::class); $fetcher->shouldNotReceive('execute'); $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(PublishStatusEnum::PUBLISHED, $routeArticle->publish_status); $this->assertNull($routeArticle->publish_error); } public function test_publish_fails_when_fallback_recovers_only_a_title(): void { $routeArticle = $this->createRouteArticle([], unvalidated: true); $fetcher = Mockery::mock(FetchArticleDataAction::class); $fetcher->shouldReceive('execute')->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(FetchArticleDataAction::class); $fetcher->shouldReceive('execute')->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(FetchArticleDataAction::class); $fetcher->shouldNotReceive('execute'); $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); } }