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);
$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<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
* @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;
$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
);
}

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]);
$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)
);
}
}

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'));
}
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'));