test(tiktok): cover cleanup edge cases and fake against the config base URL

- Multi-image carousel: only the oversized photo is resized, order preserved,
  the compliant one passes through untouched.
- Cleanup runs even when publishing fails (no orphaned derivative).
- A throwing Storage::delete does not turn a successful publish into a failure.
- Replace hardcoded https://open.tiktokapis.com/v2 with
  config('trypost.platforms.tiktok.api') so the fakes can't drift from the
  configured (overridable) host.
This commit is contained in:
Paulo Castellano 2026-06-26 13:08:18 -03:00
parent 3cf9af9caf
commit 5df854092b

View file

@ -41,6 +41,8 @@
]);
$this->publisher = new TikTokPublisher;
$this->api = config('trypost.platforms.tiktok.api');
});
test('tiktok publisher throws exception when no media', function () {
@ -62,10 +64,10 @@
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
$this->api.'/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
$this->api.'/post/publish/status/fetch/' => Http::response([
'data' => [
'status' => 'PUBLISH_COMPLETE',
'publish_id' => 'pub_123',
@ -100,10 +102,10 @@
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/content/init/' => Http::response([
$this->api.'/post/publish/content/init/' => Http::response([
'data' => ['publish_id' => 'pub_photo_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
$this->api.'/post/publish/status/fetch/' => Http::response([
'data' => [
'status' => 'PUBLISH_COMPLETE',
'publish_id' => 'pub_photo_123',
@ -141,7 +143,7 @@
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
$this->api.'/post/publish/video/init/' => Http::response([
'error' => [
'code' => 'invalid_request',
'message' => 'Invalid request',
@ -167,7 +169,7 @@
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
$this->api.'/post/publish/video/init/' => Http::response([
'error' => [
'code' => 'access_token_invalid',
'message' => 'Access token is invalid',
@ -195,15 +197,15 @@
]);
Http::fake([
'https://open.tiktokapis.com/v2/oauth/token/' => Http::response([
$this->api.'/oauth/token/' => Http::response([
'access_token' => 'new-access-token',
'refresh_token' => 'new-refresh-token',
'expires_in' => 86400,
], 200),
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
$this->api.'/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
$this->api.'/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
]);
@ -256,7 +258,7 @@
]);
Http::fake([
'*/oauth/token/' => Http::response([
$this->api.'/oauth/token/' => Http::response([
'error' => ['code' => 'invalid_grant', 'message' => 'Refresh token expired'],
], 400),
]);
@ -296,10 +298,10 @@
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
$this->api.'/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
$this->api.'/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
]);
@ -325,10 +327,10 @@
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
$this->api.'/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
$this->api.'/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
]);
@ -356,13 +358,13 @@
Http::fake([
// creator_info/query returns 500 — should not affect publishing since user picked privacy_level.
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
$this->api.'/post/publish/creator_info/query/' => Http::response([
'error' => ['code' => 'internal_error', 'message' => 'Internal server error'],
], 500),
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
$this->api.'/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_fallback_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
$this->api.'/post/publish/status/fetch/' => Http::response([
'data' => [
'status' => 'PUBLISH_COMPLETE',
'publish_id' => 'pub_fallback_123',
@ -400,10 +402,10 @@
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
$this->api.'/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
$this->api.'/post/publish/status/fetch/' => Http::response([
'data' => [
'status' => 'FAILED',
'fail_reason' => 'video_rejected',
@ -441,15 +443,15 @@
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
$this->api.'/post/publish/creator_info/query/' => Http::response([
'data' => [
'privacy_level_options' => ['PUBLIC_TO_EVERYONE', 'SELF_ONLY'],
],
], 200),
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
$this->api.'/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_meta_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
$this->api.'/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
]);
@ -496,13 +498,13 @@
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
$this->api.'/post/publish/creator_info/query/' => Http::response([
'data' => ['privacy_level_options' => ['SELF_ONLY']],
], 200),
'https://open.tiktokapis.com/v2/post/publish/content/init/' => Http::response([
$this->api.'/post/publish/content/init/' => Http::response([
'data' => ['publish_id' => 'pub_music_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
$this->api.'/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
]);
@ -546,13 +548,13 @@
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
$this->api.'/post/publish/creator_info/query/' => Http::response([
'data' => ['privacy_level_options' => ['SELF_ONLY']],
], 200),
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
$this->api.'/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_vid_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
$this->api.'/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
]);
@ -587,15 +589,15 @@
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
$this->api.'/post/publish/creator_info/query/' => Http::response([
'data' => [
'privacy_level_options' => ['PUBLIC_TO_EVERYONE', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY'],
],
], 200),
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
$this->api.'/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_default_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
$this->api.'/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
]);
@ -636,15 +638,15 @@
$this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
$this->api.'/post/publish/creator_info/query/' => Http::response([
'data' => [
'privacy_level_options' => ['SELF_ONLY'],
],
], 200),
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
$this->api.'/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_video_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
$this->api.'/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE', 'publish_id' => 'pub_video_123'],
], 200),
]);
@ -679,7 +681,7 @@
Http::fake([
// creator_info returns a healthy response — fallback would have silently picked PUBLIC_TO_EVERYONE.
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
$this->api.'/post/publish/creator_info/query/' => Http::response([
'data' => [
'creator_nickname' => 'test',
'creator_username' => 'test',
@ -725,10 +727,10 @@
app()->instance(MediaOptimizer::class, $mockOptimizer);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/content/init/' => Http::response([
$this->api.'/post/publish/content/init/' => Http::response([
'data' => ['publish_id' => 'pub_resize_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
$this->api.'/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
'*' => Http::response('fake-image-content', 200),
@ -769,10 +771,10 @@
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/content/init/' => Http::response([
$this->api.'/post/publish/content/init/' => Http::response([
'data' => ['publish_id' => 'pub_passthrough_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
$this->api.'/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
]);
@ -821,10 +823,10 @@
app()->instance(MediaOptimizer::class, $mockOptimizer);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/content/init/' => Http::response([
$this->api.'/post/publish/content/init/' => Http::response([
'data' => ['publish_id' => 'pub_unknown_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
$this->api.'/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
'*' => Http::response('fake-image-content', 200),
@ -879,3 +881,161 @@
// Nothing should be left hosted when resizing never completes.
expect(Storage::allFiles('social-tiktok-photos'))->toBeEmpty();
});
test('tiktok publisher resizes only the oversized photos in a mixed carousel', function () {
Storage::fake();
// TikTok carousels can carry many images; here one is oversized, one is compliant.
$this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]);
$this->post->update([
'media' => [
[
'id' => 'oversized',
'path' => 'media/2026-01/big.jpg',
'url' => 'https://example.com/media/2026-01/big.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'big.jpg',
'meta' => ['width' => 1254, 'height' => 1254],
],
[
'id' => 'compliant',
'path' => 'media/2026-01/small.jpg',
'url' => 'https://example.com/media/2026-01/small.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'small.jpg',
'meta' => ['width' => 1080, 'height' => 1080],
],
],
]);
$mockOptimizer = Mockery::mock(MediaOptimizer::class);
$mockOptimizer->shouldReceive('maxWidthForPlatform')->with(Platform::TikTok)->andReturn(1080);
// optimizeImage must run exactly once — only for the oversized image.
$mockOptimizer->shouldReceive('optimizeImage')->once()->with(Mockery::type('string'), Platform::TikTok)->andReturnUsing(function (string $tempFile) {
$optimized = tempnam(sys_get_temp_dir(), 'tt_opt_');
copy($tempFile, $optimized);
return $optimized;
});
app()->instance(MediaOptimizer::class, $mockOptimizer);
Http::fake([
$this->api.'/post/publish/content/init/' => Http::response([
'data' => ['publish_id' => 'pub_mixed_123'],
], 200),
$this->api.'/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
'*' => Http::response('fake-image-content', 200),
]);
$this->publisher->publish($this->postPlatform);
// Order is preserved: oversized -> hosted derivative, compliant -> original URL untouched.
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/post/publish/content/init/')) {
return false;
}
$images = data_get(json_decode($request->body(), true), 'source_info.photo_images');
return is_array($images)
&& count($images) === 2
&& str_contains($images[0], 'social-tiktok-photos/')
&& ! str_contains($images[0], 'example.com')
&& $images[1] === 'https://example.com/media/2026-01/small.jpg';
});
// The compliant image is never downloaded; only the oversized one is fetched for resizing.
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'small.jpg'));
// The single derivative is pruned after publish.
expect(Storage::allFiles('social-tiktok-photos'))->toBeEmpty();
});
test('tiktok publisher prunes the hosted derivative even when publishing fails', function () {
Storage::fake();
$this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]);
$this->post->update([
'media' => [
[
'id' => 'oversized',
'path' => 'media/2026-01/big.jpg',
'url' => 'https://example.com/media/2026-01/big.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'big.jpg',
'meta' => ['width' => 1254, 'height' => 1254],
],
],
]);
$mockOptimizer = Mockery::mock(MediaOptimizer::class);
$mockOptimizer->shouldReceive('maxWidthForPlatform')->with(Platform::TikTok)->andReturn(1080);
$mockOptimizer->shouldReceive('optimizeImage')->with(Mockery::type('string'), Platform::TikTok)->andReturnUsing(function (string $tempFile) {
$optimized = tempnam(sys_get_temp_dir(), 'tt_opt_');
copy($tempFile, $optimized);
return $optimized;
});
app()->instance(MediaOptimizer::class, $mockOptimizer);
// The derivative is hosted first, then TikTok rejects the publish: the finally
// must still remove it so a failed post never orphans a file on disk.
Http::fake([
$this->api.'/post/publish/content/init/' => Http::response([
'error' => ['code' => 'internal_error', 'message' => 'boom'],
], 500),
'*' => Http::response('fake-image-content', 200),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(TikTokPublishException::class);
expect(Storage::allFiles('social-tiktok-photos'))->toBeEmpty();
});
test('tiktok publisher still reports success when derivative cleanup throws on the storage disk', function () {
// The production default disk (r2) is configured to throw on a failed delete.
// Cleanup must never turn an already-published post into a reported failure.
$this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]);
$this->post->update([
'media' => [
[
'id' => 'oversized',
'path' => 'media/2026-01/big.jpg',
'url' => 'https://example.com/media/2026-01/big.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'big.jpg',
'meta' => ['width' => 1254, 'height' => 1254],
],
],
]);
$mockOptimizer = Mockery::mock(MediaOptimizer::class);
$mockOptimizer->shouldReceive('maxWidthForPlatform')->with(Platform::TikTok)->andReturn(1080);
$mockOptimizer->shouldReceive('optimizeImage')->with(Mockery::type('string'), Platform::TikTok)->andReturnUsing(function (string $tempFile) {
$optimized = tempnam(sys_get_temp_dir(), 'tt_opt_');
copy($tempFile, $optimized);
return $optimized;
});
app()->instance(MediaOptimizer::class, $mockOptimizer);
Storage::shouldReceive('put')->andReturnTrue();
Storage::shouldReceive('url')->andReturn('https://cdn.example.com/social-tiktok-photos/x.jpg');
Storage::shouldReceive('delete')->andThrow(new RuntimeException('r2 delete failed'));
Http::fake([
$this->api.'/post/publish/content/init/' => Http::response([
'data' => ['publish_id' => 'pub_cleanup_throws_123'],
], 200),
$this->api.'/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
'*' => Http::response('fake-image-content', 200),
]);
$result = $this->publisher->publish($this->postPlatform);
expect($result['id'])->toBe('pub_cleanup_throws_123');
});