138 - Upload the thumbnail once per publish, not per retry

This commit is contained in:
myrmidex 2026-08-13 21:06:02 +02:00
parent 34b78e6e54
commit e16368a662
3 changed files with 106 additions and 17 deletions

View file

@ -37,46 +37,63 @@ public function publishToChannel(Article $article, array $extractedData, Platfor
$authService = resolve(LemmyAuthService::class); $authService = resolve(LemmyAuthService::class);
$token = $authService->getToken($this->account); $token = $authService->getToken($this->account);
$thumbnail = $this->hostedThumbnail($extractedData, $channel, $article, $token);
try { try {
return $this->createPost($token, $extractedData, $channel, $article); return $this->createPost($token, $extractedData, $channel, $article, $thumbnail);
} catch (Exception $e) { } catch (Exception $e) {
// If the cached token was stale, refresh and retry once // If the cached token was stale, refresh and retry once
if (str_contains($e->getMessage(), 'not_logged_in') || str_contains($e->getMessage(), 'Unauthorized')) { if (str_contains($e->getMessage(), 'not_logged_in') || str_contains($e->getMessage(), 'Unauthorized')) {
$token = $authService->refreshToken($this->account); $token = $authService->refreshToken($this->account);
return $this->createPost($token, $extractedData, $channel, $article); return $this->createPost($token, $extractedData, $channel, $article, $thumbnail);
} }
throw $e; 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<string, mixed> $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<string, mixed> $extractedData * @param array<string, mixed> $extractedData
* @return array<string, mixed> * @return array<string, mixed>
*/ */
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; $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( return $this->api->createPost(
$token, $token,
$extractedData['title'] ?? 'Untitled', $extractedData['title'] ?? 'Untitled',
$extractedData['description'] ?? '', $extractedData['description'] ?? '',
$channel->channel_id, $channel->channel_id,
$article->url, $article->url,
$hosted, $thumbnail,
$languageId $languageId
); );
} }

View file

@ -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]); $apiMock->shouldReceive('createPost')->once()->andReturn(['ok' => true]);
$uploader = Mockery::mock(ThumbnailUploader::class); $uploader = Mockery::mock(ThumbnailUploader::class);
$uploader->shouldReceive('upload')->once()->with(null, 'tok')->andReturn(null); $uploader->shouldNotReceive('upload');
$publisher = new LemmyPublisher($account); $publisher = new LemmyPublisher($account);
$this->injectMocks($publisher, $apiMock, $uploader); $this->injectMocks($publisher, $apiMock, $uploader);
$this->assertSame(['ok' => true], $publisher->publishToChannel($article, ['title' => 'T'], $channel)); $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)
);
}
} }

View file

@ -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')); $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 public function test_it_returns_null_when_the_download_throws(): void
{ {
Http::fake(fn () => throw new \RuntimeException('connection refused')); Http::fake(fn () => throw new \RuntimeException('connection refused'));