diff --git a/app/Modules/Lemmy/Services/LemmyPublisher.php b/app/Modules/Lemmy/Services/LemmyPublisher.php index cce698d3..0859aea9 100644 --- a/app/Modules/Lemmy/Services/LemmyPublisher.php +++ b/app/Modules/Lemmy/Services/LemmyPublisher.php @@ -37,46 +37,63 @@ public function publishToChannel(Article $article, array $extractedData, Platfor $authService = resolve(LemmyAuthService::class); $token = $authService->getToken($this->account); + $thumbnail = $this->hostedThumbnail($extractedData, $channel, $article, $token); + try { - return $this->createPost($token, $extractedData, $channel, $article); + return $this->createPost($token, $extractedData, $channel, $article, $thumbnail); } catch (Exception $e) { // If the cached token was stale, refresh and retry once if (str_contains($e->getMessage(), 'not_logged_in') || str_contains($e->getMessage(), 'Unauthorized')) { $token = $authService->refreshToken($this->account); - return $this->createPost($token, $extractedData, $channel, $article); + return $this->createPost($token, $extractedData, $channel, $article, $thumbnail); } throw $e; } } + /** + * Uploaded once per publish, outside the stale-token retry: the upload is the expensive + * part and a retry would otherwise re-download, re-encode and re-log. + * + * @param array $extractedData + */ + private function hostedThumbnail(array $extractedData, PlatformChannel $channel, Article $article, string $token): ?string + { + $source = $extractedData['thumbnail'] ?? null; + $source = is_string($source) && $source !== '' ? $source : null; + + if ($source === null) { + return null; + } + + $hosted = $this->thumbnailUploader->upload($source, $token); + + if ($hosted === null) { + app(LogSaver::class)->warning('Thumbnail upload failed; publishing without one', $channel, [ + 'article_id' => $article->id, + 'source' => $source, + ]); + } + + return $hosted; + } + /** * @param array $extractedData * @return array */ - private function createPost(string $token, array $extractedData, PlatformChannel $channel, Article $article): array + private function createPost(string $token, array $extractedData, PlatformChannel $channel, Article $article, ?string $thumbnail = null): array { $languageId = $extractedData['language_id'] ?? null; - $thumbnail = $extractedData['thumbnail'] ?? null; - $thumbnail = is_string($thumbnail) && $thumbnail !== '' ? $thumbnail : null; - - $hosted = $this->thumbnailUploader->upload($thumbnail, $token); - - if ($thumbnail !== null && $hosted === null) { - resolve(LogSaver::class)->warning('Thumbnail upload failed; publishing without one', $channel, [ - 'article_id' => $article->id, - 'source' => $thumbnail, - ]); - } - return $this->api->createPost( $token, $extractedData['title'] ?? 'Untitled', $extractedData['description'] ?? '', $channel->channel_id, $article->url, - $hosted, + $thumbnail, $languageId ); } diff --git a/tests/Unit/Modules/Lemmy/Services/LemmyPublisherTest.php b/tests/Unit/Modules/Lemmy/Services/LemmyPublisherTest.php index 3e53f211..57428ea7 100644 --- a/tests/Unit/Modules/Lemmy/Services/LemmyPublisherTest.php +++ b/tests/Unit/Modules/Lemmy/Services/LemmyPublisherTest.php @@ -428,11 +428,75 @@ public function test_it_does_not_log_when_there_was_no_thumbnail_to_upload(): vo $apiMock->shouldReceive('createPost')->once()->andReturn(['ok' => true]); $uploader = Mockery::mock(ThumbnailUploader::class); - $uploader->shouldReceive('upload')->once()->with(null, 'tok')->andReturn(null); + $uploader->shouldNotReceive('upload'); $publisher = new LemmyPublisher($account); $this->injectMocks($publisher, $apiMock, $uploader); $this->assertSame(['ok' => true], $publisher->publishToChannel($article, ['title' => 'T'], $channel)); } + + public function test_a_stale_token_retry_does_not_re_upload_the_thumbnail(): void + { + $account = PlatformAccount::factory()->make(['instance_url' => 'https://lemmy.world']); + $article = Article::factory()->create(); + $channel = PlatformChannel::factory()->create(); + + $authMock = Mockery::mock(LemmyAuthService::class); + $authMock->shouldReceive('getToken')->once()->andReturn('stale'); + $authMock->shouldReceive('refreshToken')->once()->andReturn('fresh'); + $this->app->instance(LemmyAuthService::class, $authMock); + + $apiMock = Mockery::mock(LemmyApiService::class); + $apiMock->shouldReceive('createPost') + ->once() + ->with('stale', Mockery::any(), Mockery::any(), Mockery::any(), Mockery::any(), 'https://lemmy.world/pictrs/image/x.jpg', Mockery::any()) + ->andThrow(new Exception('not_logged_in')); + $apiMock->shouldReceive('createPost') + ->once() + ->with('fresh', Mockery::any(), Mockery::any(), Mockery::any(), Mockery::any(), 'https://lemmy.world/pictrs/image/x.jpg', Mockery::any()) + ->andReturn(['ok' => true]); + + $uploader = Mockery::mock(ThumbnailUploader::class); + $uploader->shouldReceive('upload')->once()->andReturn('https://lemmy.world/pictrs/image/x.jpg'); + + $publisher = new LemmyPublisher($account); + $this->injectMocks($publisher, $apiMock, $uploader); + + $this->assertSame( + ['ok' => true], + $publisher->publishToChannel($article, ['title' => 'T', 'thumbnail' => 'https://cdn.example.com/b.jpg'], $channel) + ); + } + + public function test_a_stale_token_retry_logs_the_upload_failure_only_once(): void + { + $account = PlatformAccount::factory()->make(['instance_url' => 'https://lemmy.world']); + $article = Article::factory()->create(); + $channel = PlatformChannel::factory()->create(); + + $authMock = Mockery::mock(LemmyAuthService::class); + $authMock->shouldReceive('getToken')->andReturn('stale'); + $authMock->shouldReceive('refreshToken')->andReturn('fresh'); + $this->app->instance(LemmyAuthService::class, $authMock); + + $logMock = Mockery::mock(LogSaver::class); + $logMock->shouldReceive('warning')->once(); + $this->app->instance(LogSaver::class, $logMock); + + $apiMock = Mockery::mock(LemmyApiService::class); + $apiMock->shouldReceive('createPost')->once()->andThrow(new Exception('not_logged_in')); + $apiMock->shouldReceive('createPost')->once()->andReturn(['ok' => true]); + + $uploader = Mockery::mock(ThumbnailUploader::class); + $uploader->shouldReceive('upload')->once()->andReturn(null); + + $publisher = new LemmyPublisher($account); + $this->injectMocks($publisher, $apiMock, $uploader); + + $this->assertSame( + ['ok' => true], + $publisher->publishToChannel($article, ['title' => 'T', 'thumbnail' => 'https://cdn.example.com/b.jpg'], $channel) + ); + } } diff --git a/tests/Unit/Modules/Lemmy/Services/ThumbnailUploaderTest.php b/tests/Unit/Modules/Lemmy/Services/ThumbnailUploaderTest.php index 10a332a5..b51ccac9 100644 --- a/tests/Unit/Modules/Lemmy/Services/ThumbnailUploaderTest.php +++ b/tests/Unit/Modules/Lemmy/Services/ThumbnailUploaderTest.php @@ -148,6 +148,14 @@ public function test_it_returns_null_when_the_source_exceeds_the_size_cap(): voi $this->assertNull($this->uploader()->upload('https://cdn.example.com/huge.jpg', 'token')); } + public function test_it_returns_null_when_the_source_exceeds_the_pixel_cap(): void + { + // A small file can still decode to a huge bitmap; this is the guard that protects the worker. + $this->fake($this->jpeg(9000, 6000), ['files' => [['file' => 'abc123.jpg']]]); + + $this->assertNull($this->uploader()->upload('https://cdn.example.com/huge-dimensions.jpg', 'token')); + } + public function test_it_returns_null_when_the_download_throws(): void { Http::fake(fn () => throw new \RuntimeException('connection refused'));