2026-01-18 18:56:51 +00:00
|
|
|
<?php
|
|
|
|
|
|
refactor: settings redesign, Spanish translations, language system, strict_types
Settings pages:
- Redesign layout to match Sendkit (max-w-4xl, space-y-12, Separator sections)
- Merge Members page into Workspace settings with Table, invite Dialog, ConfirmDeleteModal
- Add workspace logo upload/delete routes and controller methods
- Translate all hardcoded strings in Workspace.vue modals
Language system:
- Drop languages table, replace language_id FK with locale string column on users
- Create config/languages.php for available languages and default locale
- Add Spanish (es) translations (13 files)
- Simplify HandleInertiaRequests, ProfileController, RegisteredUserController
Code quality:
- Add declare(strict_types=1) to all PHP files
- Fix MastodonPublisher using wrong attribute (filename -> original_filename)
- Fix HasMediaTest for new has_photo/photo_url accessors
- Fix PublishToSocialPlatformTest type error revealed by strict_types
- Remove orphaned Language model from AppServiceProvider morph map
- Update User TypeScript interface (has_photo, photo_url, locale)
- Eager load media relation on workspaces to prevent N+1
- Add 8 new tests for workspace logo upload/delete
- Update workspace settings test to assert members/invitations props
All 710 tests passing.
2026-03-30 03:20:43 +00:00
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2026-01-18 18:56:51 +00:00
|
|
|
use App\Enums\PostPlatform\ContentType;
|
|
|
|
|
use App\Enums\SocialAccount\Platform;
|
fix: Pinterest video processing timeout — longer poll + retry (#246)
* fix: give Pinterest video processing more time and retry on timeout
A valid ~54s video pin failed after ~90s of polling while Pinterest was
still processing. Extend the poll window to ~5 minutes and treat timeout
as platform unavailable so PublishToSocialPlatform reschedules instead of
failing the post on the first attempt.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: use Laravel Sleep for Pinterest media processing polls
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: inline Pinterest video processing poll constants
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: map Pinterest media upload statuses to an enum
Use the official MediaUploadStatus values (registered, processing,
succeeded, failed) instead of comparing raw strings in the publisher.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: trim Pinterest media processing docblock
* fix: cap platform-unavailable retries and recover stuck retrying posts
Stop infinite reschedules after 6 attempts with a user-safe failure
message, keep technical detail in error_context, recover Retrying
platforms in social:recover-stuck-posts, and drop unused isTerminal().
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: remove unused failedCount in RecoverStuckPosts
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: skip final Pinterest poll sleep and localize recover timeout
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: raise publish job timeout headroom and ignore already-failed platforms
Give social publish jobs 15 minutes so Pinterest media polling fits under
the worker limit, bump Horizon/redis retry_after above that timeout, and
skip handle/failed when the platform is already Failed so delayed jobs
cannot revive posts recovered by social:recover-stuck-posts.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: restore social-publishing and ai-assistant horizon supervisors
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: harden Pinterest 401 handling, unique publish jobs, and recover JSON
Treat media-status 401 as TokenExpired, make PublishToSocialPlatform
unique per platform+attempt so retries still queue, and persist recover
error_context via Eloquent casts instead of manual json_encode.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: mass-update stuck post platforms without per-row each
Eloquent query updates already bind JSON arrays correctly here, so one
UPDATE is enough — no manual json_encode and no N model writes.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: simplify Pinterest media processing poll loop
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: simplify publish job retry and terminal status checks
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: do not finalize posts while platforms are still retrying
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: cover Pinterest timeout, unique jobs, and recover edge cases
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: retry Pinterest media poll on connection errors and tighten tests
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: remove ineffective TypeError import that breaks CI
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 23:49:37 +00:00
|
|
|
use App\Exceptions\PlatformUnavailableException;
|
|
|
|
|
use App\Exceptions\Social\PinterestPublishException;
|
2026-01-18 18:56:51 +00:00
|
|
|
use App\Exceptions\TokenExpiredException;
|
|
|
|
|
use App\Models\Post;
|
|
|
|
|
use App\Models\PostPlatform;
|
|
|
|
|
use App\Models\SocialAccount;
|
|
|
|
|
use App\Models\User;
|
|
|
|
|
use App\Models\Workspace;
|
2026-04-01 00:22:57 +00:00
|
|
|
use App\Services\Media\MediaOptimizer;
|
2026-01-18 18:56:51 +00:00
|
|
|
use App\Services\Social\PinterestPublisher;
|
fix: Pinterest video processing timeout — longer poll + retry (#246)
* fix: give Pinterest video processing more time and retry on timeout
A valid ~54s video pin failed after ~90s of polling while Pinterest was
still processing. Extend the poll window to ~5 minutes and treat timeout
as platform unavailable so PublishToSocialPlatform reschedules instead of
failing the post on the first attempt.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: use Laravel Sleep for Pinterest media processing polls
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: inline Pinterest video processing poll constants
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: map Pinterest media upload statuses to an enum
Use the official MediaUploadStatus values (registered, processing,
succeeded, failed) instead of comparing raw strings in the publisher.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: trim Pinterest media processing docblock
* fix: cap platform-unavailable retries and recover stuck retrying posts
Stop infinite reschedules after 6 attempts with a user-safe failure
message, keep technical detail in error_context, recover Retrying
platforms in social:recover-stuck-posts, and drop unused isTerminal().
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: remove unused failedCount in RecoverStuckPosts
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: skip final Pinterest poll sleep and localize recover timeout
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: raise publish job timeout headroom and ignore already-failed platforms
Give social publish jobs 15 minutes so Pinterest media polling fits under
the worker limit, bump Horizon/redis retry_after above that timeout, and
skip handle/failed when the platform is already Failed so delayed jobs
cannot revive posts recovered by social:recover-stuck-posts.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: restore social-publishing and ai-assistant horizon supervisors
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: harden Pinterest 401 handling, unique publish jobs, and recover JSON
Treat media-status 401 as TokenExpired, make PublishToSocialPlatform
unique per platform+attempt so retries still queue, and persist recover
error_context via Eloquent casts instead of manual json_encode.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: mass-update stuck post platforms without per-row each
Eloquent query updates already bind JSON arrays correctly here, so one
UPDATE is enough — no manual json_encode and no N model writes.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: simplify Pinterest media processing poll loop
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: simplify publish job retry and terminal status checks
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: do not finalize posts while platforms are still retrying
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: cover Pinterest timeout, unique jobs, and recover edge cases
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: retry Pinterest media poll on connection errors and tighten tests
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: remove ineffective TypeError import that breaks CI
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 23:49:37 +00:00
|
|
|
use Illuminate\Http\Client\ConnectionException;
|
2026-01-18 18:56:51 +00:00
|
|
|
use Illuminate\Support\Facades\Http;
|
fix: Pinterest video processing timeout — longer poll + retry (#246)
* fix: give Pinterest video processing more time and retry on timeout
A valid ~54s video pin failed after ~90s of polling while Pinterest was
still processing. Extend the poll window to ~5 minutes and treat timeout
as platform unavailable so PublishToSocialPlatform reschedules instead of
failing the post on the first attempt.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: use Laravel Sleep for Pinterest media processing polls
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: inline Pinterest video processing poll constants
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: map Pinterest media upload statuses to an enum
Use the official MediaUploadStatus values (registered, processing,
succeeded, failed) instead of comparing raw strings in the publisher.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: trim Pinterest media processing docblock
* fix: cap platform-unavailable retries and recover stuck retrying posts
Stop infinite reschedules after 6 attempts with a user-safe failure
message, keep technical detail in error_context, recover Retrying
platforms in social:recover-stuck-posts, and drop unused isTerminal().
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: remove unused failedCount in RecoverStuckPosts
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: skip final Pinterest poll sleep and localize recover timeout
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: raise publish job timeout headroom and ignore already-failed platforms
Give social publish jobs 15 minutes so Pinterest media polling fits under
the worker limit, bump Horizon/redis retry_after above that timeout, and
skip handle/failed when the platform is already Failed so delayed jobs
cannot revive posts recovered by social:recover-stuck-posts.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: restore social-publishing and ai-assistant horizon supervisors
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: harden Pinterest 401 handling, unique publish jobs, and recover JSON
Treat media-status 401 as TokenExpired, make PublishToSocialPlatform
unique per platform+attempt so retries still queue, and persist recover
error_context via Eloquent casts instead of manual json_encode.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: mass-update stuck post platforms without per-row each
Eloquent query updates already bind JSON arrays correctly here, so one
UPDATE is enough — no manual json_encode and no N model writes.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: simplify Pinterest media processing poll loop
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: simplify publish job retry and terminal status checks
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: do not finalize posts while platforms are still retrying
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: cover Pinterest timeout, unique jobs, and recover edge cases
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: retry Pinterest media poll on connection errors and tighten tests
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: remove ineffective TypeError import that breaks CI
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 23:49:37 +00:00
|
|
|
use Illuminate\Support\Sleep;
|
2026-01-18 18:56:51 +00:00
|
|
|
|
|
|
|
|
beforeEach(function () {
|
|
|
|
|
$this->user = User::factory()->create();
|
|
|
|
|
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
|
|
|
|
|
|
$this->socialAccount = SocialAccount::factory()->pinterest()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform_user_id' => 'pinterest_user_123',
|
|
|
|
|
'username' => 'pinner',
|
|
|
|
|
'token_expires_at' => now()->addDays(30),
|
|
|
|
|
'meta' => [
|
|
|
|
|
'default_board_id' => 'board_123',
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
2026-04-15 23:11:36 +00:00
|
|
|
'content' => 'Check out this pin!',
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->postPlatform = PostPlatform::factory()->pinterest()->create([
|
|
|
|
|
'post_id' => $this->post->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
'platform' => Platform::Pinterest,
|
|
|
|
|
'content_type' => ContentType::PinterestPin,
|
|
|
|
|
'meta' => ['board_id' => 'board_123'],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher = new PinterestPublisher;
|
2026-04-01 00:22:57 +00:00
|
|
|
|
|
|
|
|
// Mock MediaOptimizer to return the same temp file path
|
|
|
|
|
$mockOptimizer = Mockery::mock(MediaOptimizer::class);
|
|
|
|
|
$mockOptimizer->shouldReceive('optimizeImage')->andReturnUsing(function (string $tempFile) {
|
|
|
|
|
// Copy to a new temp file to simulate optimization
|
|
|
|
|
$optimized = tempnam(sys_get_temp_dir(), 'pin_opt_');
|
|
|
|
|
copy($tempFile, $optimized);
|
|
|
|
|
|
|
|
|
|
return $optimized;
|
|
|
|
|
});
|
|
|
|
|
app()->instance(MediaOptimizer::class, $mockOptimizer);
|
2026-01-18 18:56:51 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher can publish image pin', function () {
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'image.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'*/v5/pins' => Http::response([
|
|
|
|
|
'id' => 'pin_123456',
|
|
|
|
|
], 200),
|
2026-04-01 00:22:57 +00:00
|
|
|
'*' => Http::response('fake-image-content', 200),
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result)->toHaveKey('id');
|
|
|
|
|
expect($result)->toHaveKey('url');
|
|
|
|
|
expect($result['id'])->toBe('pin_123456');
|
|
|
|
|
expect($result['url'])->toBe('https://pinterest.com/pin/pin_123456');
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_contains($request->url(), '/v5/pins');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher throws exception when no media for pin', function () {
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(Exception::class, 'Pinterest requires at least one image');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher throws exception when no board id', function () {
|
|
|
|
|
$this->postPlatform->update(['meta' => []]);
|
|
|
|
|
$this->socialAccount->update(['meta' => []]);
|
|
|
|
|
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'image.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(Exception::class, 'Pinterest board_id is required');
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-15 14:51:20 +00:00
|
|
|
test('pinterest publisher throws exception for carousel when no board id', function () {
|
|
|
|
|
$this->postPlatform->update([
|
|
|
|
|
'content_type' => ContentType::PinterestCarousel,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
]);
|
|
|
|
|
$this->socialAccount->update(['meta' => []]);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
['id' => 'm1', 'path' => 'media/img1.jpg', 'url' => 'https://example.com/img1.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'img1.jpg'],
|
|
|
|
|
['id' => 'm2', 'path' => 'media/img2.jpg', 'url' => 'https://example.com/img2.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'img2.jpg'],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(Exception::class, 'Pinterest board_id is required');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher throws exception for video pin when no board id', function () {
|
|
|
|
|
$this->postPlatform->update([
|
|
|
|
|
'content_type' => ContentType::PinterestVideoPin,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
]);
|
|
|
|
|
$this->socialAccount->update(['meta' => []]);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
['id' => 'm1', 'path' => 'media/video.mp4', 'url' => 'https://example.com/video.mp4', 'mime_type' => 'video/mp4', 'original_filename' => 'video.mp4'],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(Exception::class, 'Pinterest board_id is required');
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-18 18:56:51 +00:00
|
|
|
test('pinterest publisher uses default board id from account', function () {
|
|
|
|
|
$this->postPlatform->update(['meta' => []]); // No board_id in post meta
|
|
|
|
|
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'image.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'*/v5/pins' => Http::response([
|
|
|
|
|
'id' => 'pin_123456',
|
|
|
|
|
], 200),
|
2026-04-01 00:22:57 +00:00
|
|
|
'*' => Http::response('fake-image-content', 200),
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
2026-04-01 00:22:57 +00:00
|
|
|
return str_contains($request->url(), '/v5/pins')
|
|
|
|
|
&& $request['board_id'] === 'board_123'; // from account meta
|
2026-01-18 18:56:51 +00:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher can publish carousel', function () {
|
2026-04-15 23:11:36 +00:00
|
|
|
$mediaItems = [];
|
2026-01-18 18:56:51 +00:00
|
|
|
for ($i = 1; $i <= 3; $i++) {
|
2026-04-15 23:11:36 +00:00
|
|
|
$mediaItems[] = [
|
|
|
|
|
'id' => "test-media-{$i}",
|
2026-01-18 18:56:51 +00:00
|
|
|
'path' => "media/2026-01/image{$i}.jpg",
|
2026-04-15 23:11:36 +00:00
|
|
|
'url' => "https://example.com/media/2026-01/image{$i}.jpg",
|
2026-01-18 18:56:51 +00:00
|
|
|
'mime_type' => 'image/jpeg',
|
2026-04-15 23:11:36 +00:00
|
|
|
'original_filename' => "image{$i}.jpg",
|
|
|
|
|
];
|
2026-01-18 18:56:51 +00:00
|
|
|
}
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->postPlatform->update(['content_type' => ContentType::PinterestCarousel]);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
|
|
|
|
|
'media' => $mediaItems,
|
|
|
|
|
|
|
|
|
|
]);
|
2026-01-18 18:56:51 +00:00
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'*/v5/pins' => Http::response([
|
|
|
|
|
'id' => 'carousel_pin_123',
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('carousel_pin_123');
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return $request['media_source']['source_type'] === 'multiple_image_urls'
|
|
|
|
|
&& count($request['media_source']['items']) === 3;
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher throws exception for carousel with less than 2 images', function () {
|
|
|
|
|
$this->postPlatform->update(['content_type' => ContentType::PinterestCarousel]);
|
|
|
|
|
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'image.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(Exception::class, 'Pinterest carousel requires 2-5 images');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher throws exception for carousel with more than 5 images', function () {
|
2026-04-15 23:11:36 +00:00
|
|
|
$mediaItems = [];
|
2026-01-18 18:56:51 +00:00
|
|
|
for ($i = 1; $i <= 6; $i++) {
|
2026-04-15 23:11:36 +00:00
|
|
|
$mediaItems[] = [
|
|
|
|
|
'id' => "test-media-{$i}",
|
2026-01-18 18:56:51 +00:00
|
|
|
'path' => "media/2026-01/image{$i}.jpg",
|
2026-04-15 23:11:36 +00:00
|
|
|
'url' => "https://example.com/media/2026-01/image{$i}.jpg",
|
2026-01-18 18:56:51 +00:00
|
|
|
'mime_type' => 'image/jpeg',
|
2026-04-15 23:11:36 +00:00
|
|
|
'original_filename' => "image{$i}.jpg",
|
|
|
|
|
];
|
2026-01-18 18:56:51 +00:00
|
|
|
}
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->postPlatform->update(['content_type' => ContentType::PinterestCarousel]);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
|
|
|
|
|
'media' => $mediaItems,
|
|
|
|
|
|
|
|
|
|
]);
|
2026-01-18 18:56:51 +00:00
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(Exception::class, 'Pinterest carousel requires 2-5 images');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher throws exception on api error', function () {
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'image.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'*/v5/pins' => Http::response([
|
|
|
|
|
'code' => 400,
|
|
|
|
|
'message' => 'Invalid request',
|
|
|
|
|
], 400),
|
2026-04-01 00:22:57 +00:00
|
|
|
'*' => Http::response('fake-image-content', 200),
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(Exception::class);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher throws token expired exception on auth error', function () {
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'image.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'*/v5/pins' => Http::response([
|
|
|
|
|
'code' => 1,
|
|
|
|
|
'message' => 'Invalid access token',
|
|
|
|
|
], 401),
|
2026-04-01 00:22:57 +00:00
|
|
|
'*' => Http::response('fake-image-content', 200),
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(TokenExpiredException::class);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher refreshes token when expired', function () {
|
|
|
|
|
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
|
|
|
|
|
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'image.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'*/v5/oauth/token' => Http::response([
|
|
|
|
|
'access_token' => 'new-access-token',
|
|
|
|
|
'refresh_token' => 'new-refresh-token',
|
|
|
|
|
'expires_in' => 2592000,
|
|
|
|
|
], 200),
|
|
|
|
|
'*/v5/pins' => Http::response([
|
|
|
|
|
'id' => 'pin_123456',
|
|
|
|
|
], 200),
|
2026-04-01 00:22:57 +00:00
|
|
|
'*' => Http::response('fake-image-content', 200),
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_contains($request->url(), 'oauth/token');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$this->socialAccount->refresh();
|
|
|
|
|
expect($this->socialAccount->access_token)->toBe('new-access-token');
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-12 21:56:52 +00:00
|
|
|
test('pinterest publisher throws TokenExpiredException when refresh_token is rejected', function () {
|
|
|
|
|
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'image.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'*/v5/oauth/token' => Http::response([
|
|
|
|
|
'error' => 'invalid_request',
|
|
|
|
|
'error_description' => 'Refresh token expired',
|
|
|
|
|
], 400),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(TokenExpiredException::class, 'Refresh token expired');
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-18 18:56:51 +00:00
|
|
|
test('pinterest publisher includes title and link when provided', function () {
|
|
|
|
|
$this->postPlatform->update([
|
|
|
|
|
'meta' => [
|
|
|
|
|
'board_id' => 'board_123',
|
|
|
|
|
'title' => 'My Pin Title',
|
|
|
|
|
'link' => 'https://example.com/my-page',
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update([
|
Add optional Pinterest pin title and destination link (#232)
* Add optional Pinterest pin title, description, and link.
Expose title/description/link across web, API, and MCP; seed description from caption into meta on save, and publish description only from meta.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Expand Pinterest title/description/link test coverage.
Cover web draft persistence and validation bounds, API/MCP update merge and seed, and publisher payload fields on video and carousel pins.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify Pinterest: description is post content again.
Keep optional title and link in meta/settings only. Remove the separate description textarea, seed logic, and meta.description path so Pinterest follows the shared caption pattern.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Refactor Pinterest meta handling and validation.
- Update CreatePost and UpdatePost actions to filter out null values from meta fields.
- Introduce a new method in PinterestPublisher to resolve board IDs, ensuring required fields are validated.
- Enhance PinterestSettings component to manage title and link inputs, including validation for HTTP URLs.
- Update PostPlatformMetaRules to enforce URL validation for Pinterest links.
- Add tests for clearing Pinterest title and link, and for rejecting invalid links during scheduling.
This refactor improves the handling of Pinterest metadata and enhances user experience by ensuring proper validation and error handling.
* Add validation messages and attributes for Pinterest meta fields
- Introduced custom validation messages and friendly attribute names for Pinterest link and title fields in PostPlatformMetaRules.
- Updated StorePostRequest, UpdatePostRequest, and related tools to utilize these new messages and attributes.
- Enhanced tests to assert correct error messages for invalid Pinterest links and title length constraints.
This update improves user feedback during post creation and editing, ensuring clarity in validation errors.
* Remove click.prevent directive from Pinterest link in PinterestPreview component.
This change simplifies the link behavior, allowing default click actions to occur, which may enhance user interaction with the Pinterest link.
* Update validation error messages for Pinterest meta fields in tests
- Refined the assertions in PostApiPlatformMetaTest to include localized validation messages for Pinterest title and link fields.
- Ensured that error messages reflect the updated validation rules, enhancing clarity for users during post creation and editing.
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 21:15:50 +00:00
|
|
|
'content' => 'Check out this pin!',
|
2026-04-15 23:11:36 +00:00
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'image.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'*/v5/pins' => Http::response([
|
|
|
|
|
'id' => 'pin_123456',
|
|
|
|
|
], 200),
|
2026-04-01 00:22:57 +00:00
|
|
|
'*' => Http::response('fake-image-content', 200),
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
Add optional Pinterest pin title and destination link (#232)
* Add optional Pinterest pin title, description, and link.
Expose title/description/link across web, API, and MCP; seed description from caption into meta on save, and publish description only from meta.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Expand Pinterest title/description/link test coverage.
Cover web draft persistence and validation bounds, API/MCP update merge and seed, and publisher payload fields on video and carousel pins.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify Pinterest: description is post content again.
Keep optional title and link in meta/settings only. Remove the separate description textarea, seed logic, and meta.description path so Pinterest follows the shared caption pattern.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Refactor Pinterest meta handling and validation.
- Update CreatePost and UpdatePost actions to filter out null values from meta fields.
- Introduce a new method in PinterestPublisher to resolve board IDs, ensuring required fields are validated.
- Enhance PinterestSettings component to manage title and link inputs, including validation for HTTP URLs.
- Update PostPlatformMetaRules to enforce URL validation for Pinterest links.
- Add tests for clearing Pinterest title and link, and for rejecting invalid links during scheduling.
This refactor improves the handling of Pinterest metadata and enhances user experience by ensuring proper validation and error handling.
* Add validation messages and attributes for Pinterest meta fields
- Introduced custom validation messages and friendly attribute names for Pinterest link and title fields in PostPlatformMetaRules.
- Updated StorePostRequest, UpdatePostRequest, and related tools to utilize these new messages and attributes.
- Enhanced tests to assert correct error messages for invalid Pinterest links and title length constraints.
This update improves user feedback during post creation and editing, ensuring clarity in validation errors.
* Remove click.prevent directive from Pinterest link in PinterestPreview component.
This change simplifies the link behavior, allowing default click actions to occur, which may enhance user interaction with the Pinterest link.
* Update validation error messages for Pinterest meta fields in tests
- Refined the assertions in PostApiPlatformMetaTest to include localized validation messages for Pinterest title and link fields.
- Ensured that error messages reflect the updated validation rules, enhancing clarity for users during post creation and editing.
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 21:15:50 +00:00
|
|
|
$this->publisher->publish($this->postPlatform->fresh());
|
2026-01-18 18:56:51 +00:00
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
2026-04-01 00:22:57 +00:00
|
|
|
return str_contains($request->url(), '/v5/pins')
|
|
|
|
|
&& $request['title'] === 'My Pin Title'
|
Add optional Pinterest pin title and destination link (#232)
* Add optional Pinterest pin title, description, and link.
Expose title/description/link across web, API, and MCP; seed description from caption into meta on save, and publish description only from meta.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Expand Pinterest title/description/link test coverage.
Cover web draft persistence and validation bounds, API/MCP update merge and seed, and publisher payload fields on video and carousel pins.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify Pinterest: description is post content again.
Keep optional title and link in meta/settings only. Remove the separate description textarea, seed logic, and meta.description path so Pinterest follows the shared caption pattern.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Refactor Pinterest meta handling and validation.
- Update CreatePost and UpdatePost actions to filter out null values from meta fields.
- Introduce a new method in PinterestPublisher to resolve board IDs, ensuring required fields are validated.
- Enhance PinterestSettings component to manage title and link inputs, including validation for HTTP URLs.
- Update PostPlatformMetaRules to enforce URL validation for Pinterest links.
- Add tests for clearing Pinterest title and link, and for rejecting invalid links during scheduling.
This refactor improves the handling of Pinterest metadata and enhances user experience by ensuring proper validation and error handling.
* Add validation messages and attributes for Pinterest meta fields
- Introduced custom validation messages and friendly attribute names for Pinterest link and title fields in PostPlatformMetaRules.
- Updated StorePostRequest, UpdatePostRequest, and related tools to utilize these new messages and attributes.
- Enhanced tests to assert correct error messages for invalid Pinterest links and title length constraints.
This update improves user feedback during post creation and editing, ensuring clarity in validation errors.
* Remove click.prevent directive from Pinterest link in PinterestPreview component.
This change simplifies the link behavior, allowing default click actions to occur, which may enhance user interaction with the Pinterest link.
* Update validation error messages for Pinterest meta fields in tests
- Refined the assertions in PostApiPlatformMetaTest to include localized validation messages for Pinterest title and link fields.
- Ensured that error messages reflect the updated validation rules, enhancing clarity for users during post creation and editing.
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 21:15:50 +00:00
|
|
|
&& $request['link'] === 'https://example.com/my-page'
|
|
|
|
|
&& $request['description'] === 'Check out this pin!';
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher uses post content as description and ignores meta description', function () {
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'content' => 'Shared post caption',
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'image.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->postPlatform->update([
|
|
|
|
|
'meta' => [
|
|
|
|
|
'board_id' => 'board_123',
|
|
|
|
|
'description' => 'Stale meta description must be ignored',
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'*/v5/pins' => Http::response(['id' => 'pin_123456'], 200),
|
|
|
|
|
'*' => Http::response('fake-image-content', 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform->fresh());
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_contains($request->url(), '/v5/pins')
|
|
|
|
|
&& $request['description'] === 'Shared post caption';
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher omits description when post content is blank', function () {
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'content' => '',
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'image.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'*/v5/pins' => Http::response(['id' => 'pin_123456'], 200),
|
|
|
|
|
'*' => Http::response('fake-image-content', 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform->fresh());
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_contains($request->url(), '/v5/pins')
|
|
|
|
|
&& ! array_key_exists('description', $request->data());
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher truncates title to 100 characters', function () {
|
|
|
|
|
$longTitle = str_repeat('T', 150);
|
|
|
|
|
|
|
|
|
|
$this->postPlatform->update([
|
|
|
|
|
'meta' => [
|
|
|
|
|
'board_id' => 'board_123',
|
|
|
|
|
'title' => $longTitle,
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'content' => 'Within limit',
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'image.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'*/v5/pins' => Http::response(['id' => 'pin_truncated'], 200),
|
|
|
|
|
'*' => Http::response('fake-image-content', 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform->fresh());
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_contains($request->url(), '/v5/pins')
|
|
|
|
|
&& mb_strlen($request['title']) === 100
|
|
|
|
|
&& $request['description'] === 'Within limit';
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher omits blank title and link', function () {
|
|
|
|
|
$this->postPlatform->update([
|
|
|
|
|
'meta' => [
|
|
|
|
|
'board_id' => 'board_123',
|
|
|
|
|
'title' => '',
|
|
|
|
|
'link' => '',
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'content' => 'Caption only',
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'image.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'*/v5/pins' => Http::response(['id' => 'pin_no_title_link'], 200),
|
|
|
|
|
'*' => Http::response('fake-image-content', 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform->fresh());
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
$data = $request->data();
|
|
|
|
|
|
|
|
|
|
return str_contains($request->url(), '/v5/pins')
|
|
|
|
|
&& $data['description'] === 'Caption only'
|
|
|
|
|
&& ! array_key_exists('title', $data)
|
|
|
|
|
&& ! array_key_exists('link', $data);
|
2026-01-18 18:56:51 +00:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-11 12:29:39 +00:00
|
|
|
test('pinterest publisher sends alt text from image meta capped to platform max', function () {
|
|
|
|
|
$longAlt = str_repeat('x', 600);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'image.jpg',
|
|
|
|
|
'meta' => ['alt_text' => $longAlt],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'*/v5/pins' => Http::response([
|
|
|
|
|
'id' => 'pin_123456',
|
|
|
|
|
], 200),
|
|
|
|
|
'*' => Http::response('fake-image-content', 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
$expectedAlt = mb_substr($longAlt, 0, Platform::Pinterest->altTextMaxLength());
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) use ($expectedAlt) {
|
|
|
|
|
return str_contains($request->url(), '/v5/pins')
|
|
|
|
|
&& data_get($request->data(), 'alt_text') === $expectedAlt
|
|
|
|
|
&& strlen($expectedAlt) === Platform::Pinterest->altTextMaxLength();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher omits alt_text when image has no alt text set', function () {
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'image.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'*/v5/pins' => Http::response([
|
|
|
|
|
'id' => 'pin_123456',
|
|
|
|
|
], 200),
|
|
|
|
|
'*' => Http::response('fake-image-content', 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_contains($request->url(), '/v5/pins')
|
|
|
|
|
&& ! array_key_exists('alt_text', $request->data());
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher ignores postPlatform meta alt_text and only reads image alt', function () {
|
|
|
|
|
$this->postPlatform->update([
|
|
|
|
|
'meta' => [
|
|
|
|
|
'board_id' => 'board_123',
|
|
|
|
|
'alt_text' => 'legacy per-platform alt text that should be ignored',
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'image.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'*/v5/pins' => Http::response([
|
|
|
|
|
'id' => 'pin_123456',
|
|
|
|
|
], 200),
|
|
|
|
|
'*' => Http::response('fake-image-content', 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_contains($request->url(), '/v5/pins')
|
|
|
|
|
&& ! array_key_exists('alt_text', $request->data());
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-18 18:56:51 +00:00
|
|
|
test('pinterest publisher can get boards', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'*/v5/boards*' => Http::response([
|
|
|
|
|
'items' => [
|
|
|
|
|
['id' => 'board_1', 'name' => 'Board 1'],
|
|
|
|
|
['id' => 'board_2', 'name' => 'Board 2'],
|
|
|
|
|
],
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-07-25 01:49:04 +00:00
|
|
|
$result = $this->publisher->getBoards($this->socialAccount);
|
2026-01-18 18:56:51 +00:00
|
|
|
|
2026-07-25 01:49:04 +00:00
|
|
|
expect($result['boards'])->toHaveCount(2)
|
|
|
|
|
->and($result['boards'][0]['id'])->toBe('board_1')
|
|
|
|
|
->and($result['truncated'])->toBeFalse();
|
2026-01-18 18:56:51 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-25 01:00:07 +00:00
|
|
|
test('pinterest publisher paginates boards via bookmark', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
config('trypost.platforms.pinterest.api').'/boards*' => Http::sequence()
|
|
|
|
|
->push([
|
|
|
|
|
'items' => [
|
|
|
|
|
['id' => 'board_1', 'name' => 'Board 1'],
|
|
|
|
|
],
|
|
|
|
|
'bookmark' => 'page-2',
|
|
|
|
|
], 200)
|
|
|
|
|
->push([
|
|
|
|
|
'items' => [
|
|
|
|
|
['id' => 'board_2', 'name' => 'Board 2'],
|
|
|
|
|
],
|
2026-07-25 01:12:50 +00:00
|
|
|
'bookmark' => 'page-3',
|
|
|
|
|
], 200)
|
|
|
|
|
->push([
|
|
|
|
|
'items' => [
|
|
|
|
|
['id' => 'board_3', 'name' => 'Board 3'],
|
|
|
|
|
],
|
2026-07-25 01:00:07 +00:00
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-07-25 01:49:04 +00:00
|
|
|
$result = $this->publisher->getBoards($this->socialAccount);
|
2026-07-25 01:00:07 +00:00
|
|
|
|
2026-07-25 01:49:04 +00:00
|
|
|
expect($result['boards'])->toHaveCount(3)
|
|
|
|
|
->and($result['boards'][0]['id'])->toBe('board_1')
|
|
|
|
|
->and($result['boards'][1]['id'])->toBe('board_2')
|
|
|
|
|
->and($result['boards'][2]['id'])->toBe('board_3')
|
|
|
|
|
->and($result['truncated'])->toBeFalse();
|
2026-07-25 01:12:50 +00:00
|
|
|
|
|
|
|
|
Http::assertSentCount(3);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher stops board pagination on a repeated bookmark', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
config('trypost.platforms.pinterest.api').'/boards*' => Http::sequence()
|
|
|
|
|
->push([
|
|
|
|
|
'items' => [['id' => 'board_1', 'name' => 'Board 1']],
|
|
|
|
|
'bookmark' => 'stuck',
|
|
|
|
|
], 200)
|
|
|
|
|
->push([
|
|
|
|
|
'items' => [['id' => 'board_2', 'name' => 'Board 2']],
|
|
|
|
|
'bookmark' => 'stuck',
|
|
|
|
|
], 200)
|
|
|
|
|
->push([
|
|
|
|
|
'items' => [['id' => 'should_not_fetch', 'name' => 'Nope']],
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-07-25 01:49:04 +00:00
|
|
|
$result = $this->publisher->getBoards($this->socialAccount);
|
2026-07-25 01:12:50 +00:00
|
|
|
|
2026-07-25 01:49:04 +00:00
|
|
|
expect($result['boards'])->toHaveCount(2)
|
|
|
|
|
->and(collect($result['boards'])->pluck('id')->all())->toBe(['board_1', 'board_2'])
|
|
|
|
|
->and($result['truncated'])->toBeTrue();
|
2026-07-25 01:00:07 +00:00
|
|
|
|
|
|
|
|
Http::assertSentCount(2);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-01 12:32:43 +00:00
|
|
|
test('pinterest publisher can publish video pin', function () {
|
|
|
|
|
$this->postPlatform->update(['content_type' => ContentType::PinterestVideoPin]);
|
|
|
|
|
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-video',
|
|
|
|
|
'path' => 'media/2026-01/video.mp4',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/video.mp4',
|
|
|
|
|
'mime_type' => 'video/mp4',
|
|
|
|
|
'original_filename' => 'video.mp4',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
|
2026-04-01 12:32:43 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$s3UploadUrl = 'https://pinterest-media-upload.s3.amazonaws.com/upload';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($s3UploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
// Step 1: Register media
|
|
|
|
|
if (str_contains($url, '/v5/media') && $request->method() === 'POST') {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'media_id' => 'media_video_789',
|
|
|
|
|
'upload_url' => $s3UploadUrl,
|
|
|
|
|
'upload_parameters' => [
|
|
|
|
|
'key' => 'uploads/video.mp4',
|
|
|
|
|
'AWSAccessKeyId' => 'FAKE_KEY',
|
|
|
|
|
],
|
|
|
|
|
], 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Step 2: S3 upload
|
|
|
|
|
if ($url === $s3UploadUrl) {
|
|
|
|
|
return Http::response('', 204);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Step 3: Media status check
|
|
|
|
|
if (str_contains($url, '/v5/media/media_video_789')) {
|
|
|
|
|
return Http::response(['status' => 'succeeded'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Step 4: Create pin
|
|
|
|
|
if (str_contains($url, '/v5/pins')) {
|
|
|
|
|
return Http::response(['id' => 'video_pin_999'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Video download
|
|
|
|
|
return Http::response('fake-video-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result)->toHaveKey('id');
|
|
|
|
|
expect($result)->toHaveKey('url');
|
|
|
|
|
expect($result['id'])->toBe('video_pin_999');
|
|
|
|
|
expect($result['url'])->toBe('https://pinterest.com/pin/video_pin_999');
|
|
|
|
|
|
2026-06-25 22:59:51 +00:00
|
|
|
Http::assertSent(fn ($request) => $request->url() === config('trypost.platforms.pinterest.api').'/pins'
|
|
|
|
|
&& data_get($request->data(), 'media_source.source_type') === 'video_id'
|
|
|
|
|
&& data_get($request->data(), 'media_source.cover_image_key_frame_time') === 0);
|
|
|
|
|
});
|
|
|
|
|
|
Add optional Pinterest pin title and destination link (#232)
* Add optional Pinterest pin title, description, and link.
Expose title/description/link across web, API, and MCP; seed description from caption into meta on save, and publish description only from meta.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Expand Pinterest title/description/link test coverage.
Cover web draft persistence and validation bounds, API/MCP update merge and seed, and publisher payload fields on video and carousel pins.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify Pinterest: description is post content again.
Keep optional title and link in meta/settings only. Remove the separate description textarea, seed logic, and meta.description path so Pinterest follows the shared caption pattern.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Refactor Pinterest meta handling and validation.
- Update CreatePost and UpdatePost actions to filter out null values from meta fields.
- Introduce a new method in PinterestPublisher to resolve board IDs, ensuring required fields are validated.
- Enhance PinterestSettings component to manage title and link inputs, including validation for HTTP URLs.
- Update PostPlatformMetaRules to enforce URL validation for Pinterest links.
- Add tests for clearing Pinterest title and link, and for rejecting invalid links during scheduling.
This refactor improves the handling of Pinterest metadata and enhances user experience by ensuring proper validation and error handling.
* Add validation messages and attributes for Pinterest meta fields
- Introduced custom validation messages and friendly attribute names for Pinterest link and title fields in PostPlatformMetaRules.
- Updated StorePostRequest, UpdatePostRequest, and related tools to utilize these new messages and attributes.
- Enhanced tests to assert correct error messages for invalid Pinterest links and title length constraints.
This update improves user feedback during post creation and editing, ensuring clarity in validation errors.
* Remove click.prevent directive from Pinterest link in PinterestPreview component.
This change simplifies the link behavior, allowing default click actions to occur, which may enhance user interaction with the Pinterest link.
* Update validation error messages for Pinterest meta fields in tests
- Refined the assertions in PostApiPlatformMetaTest to include localized validation messages for Pinterest title and link fields.
- Ensured that error messages reflect the updated validation rules, enhancing clarity for users during post creation and editing.
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 21:15:50 +00:00
|
|
|
test('pinterest publisher includes title description and link on video pins', function () {
|
|
|
|
|
$this->postPlatform->update([
|
|
|
|
|
'content_type' => ContentType::PinterestVideoPin,
|
|
|
|
|
'meta' => [
|
|
|
|
|
'board_id' => 'board_123',
|
|
|
|
|
'title' => 'Video Title',
|
|
|
|
|
'link' => 'https://example.com/video',
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'content' => 'Video pin caption',
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-video',
|
|
|
|
|
'path' => 'media/2026-01/video.mp4',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/video.mp4',
|
|
|
|
|
'mime_type' => 'video/mp4',
|
|
|
|
|
'original_filename' => 'video.mp4',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$s3UploadUrl = 'https://pinterest-media-upload.s3.amazonaws.com/upload';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($s3UploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/media') && $request->method() === 'POST') {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'media_id' => 'media_video_789',
|
|
|
|
|
'upload_url' => $s3UploadUrl,
|
|
|
|
|
'upload_parameters' => ['key' => 'uploads/video.mp4'],
|
|
|
|
|
], 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $s3UploadUrl) {
|
|
|
|
|
return Http::response('', 204);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/media/media_video_789')) {
|
|
|
|
|
return Http::response(['status' => 'succeeded'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/pins')) {
|
|
|
|
|
return Http::response(['id' => 'video_pin_999'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-video-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform->fresh());
|
|
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => $request->url() === config('trypost.platforms.pinterest.api').'/pins'
|
|
|
|
|
&& data_get($request->data(), 'title') === 'Video Title'
|
|
|
|
|
&& data_get($request->data(), 'description') === 'Video pin caption'
|
|
|
|
|
&& data_get($request->data(), 'link') === 'https://example.com/video'
|
|
|
|
|
&& data_get($request->data(), 'media_source.source_type') === 'video_id');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher includes title description and link on carousels', function () {
|
|
|
|
|
$mediaItems = [];
|
|
|
|
|
for ($i = 1; $i <= 2; $i++) {
|
|
|
|
|
$mediaItems[] = [
|
|
|
|
|
'id' => "test-media-{$i}",
|
|
|
|
|
'path' => "media/2026-01/image{$i}.jpg",
|
|
|
|
|
'url' => "https://example.com/media/2026-01/image{$i}.jpg",
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => "image{$i}.jpg",
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->postPlatform->update([
|
|
|
|
|
'content_type' => ContentType::PinterestCarousel,
|
|
|
|
|
'meta' => [
|
|
|
|
|
'board_id' => 'board_123',
|
|
|
|
|
'title' => 'Carousel Title',
|
|
|
|
|
'link' => 'https://example.com/carousel',
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'content' => 'Carousel pin caption',
|
|
|
|
|
'media' => $mediaItems,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'*/v5/pins' => Http::response(['id' => 'carousel_pin_123'], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform->fresh());
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_contains($request->url(), '/v5/pins')
|
|
|
|
|
&& $request['title'] === 'Carousel Title'
|
|
|
|
|
&& $request['description'] === 'Carousel pin caption'
|
|
|
|
|
&& $request['link'] === 'https://example.com/carousel'
|
|
|
|
|
&& $request['media_source']['source_type'] === 'multiple_image_urls';
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-25 22:59:51 +00:00
|
|
|
test('pinterest video pin uses the provided cover image url over the default frame', function () {
|
|
|
|
|
$this->postPlatform->update([
|
|
|
|
|
'content_type' => ContentType::PinterestVideoPin,
|
|
|
|
|
'meta' => ['board_id' => 'board_123', 'cover_image_url' => 'https://example.com/cover.jpg'],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'test-media-video',
|
|
|
|
|
'path' => 'media/2026-01/video.mp4',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/video.mp4',
|
|
|
|
|
'mime_type' => 'video/mp4',
|
|
|
|
|
'original_filename' => 'video.mp4',
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$s3UploadUrl = 'https://pinterest-media-upload.s3.amazonaws.com/upload';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($s3UploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/media') && $request->method() === 'POST') {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'media_id' => 'media_video_789',
|
|
|
|
|
'upload_url' => $s3UploadUrl,
|
|
|
|
|
'upload_parameters' => [],
|
|
|
|
|
], 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $s3UploadUrl) {
|
|
|
|
|
return Http::response('', 204);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/media/media_video_789')) {
|
|
|
|
|
return Http::response(['status' => 'succeeded'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/pins')) {
|
|
|
|
|
return Http::response(['id' => 'video_pin_cover'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-video-content', 200);
|
2026-04-01 12:32:43 +00:00
|
|
|
});
|
2026-06-25 22:59:51 +00:00
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => $request->url() === config('trypost.platforms.pinterest.api').'/pins'
|
|
|
|
|
&& data_get($request->data(), 'media_source.cover_image_url') === 'https://example.com/cover.jpg'
|
|
|
|
|
&& data_get($request->data(), 'media_source.cover_image_key_frame_time') === null);
|
2026-04-01 12:32:43 +00:00
|
|
|
});
|
|
|
|
|
|
2026-01-18 18:56:51 +00:00
|
|
|
test('pinterest publisher throws exception for unsupported content type', function () {
|
|
|
|
|
$this->postPlatform->update(['content_type' => ContentType::InstagramFeed]);
|
|
|
|
|
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'image.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(Exception::class, 'Unsupported content type');
|
|
|
|
|
});
|
fix: Pinterest video processing timeout — longer poll + retry (#246)
* fix: give Pinterest video processing more time and retry on timeout
A valid ~54s video pin failed after ~90s of polling while Pinterest was
still processing. Extend the poll window to ~5 minutes and treat timeout
as platform unavailable so PublishToSocialPlatform reschedules instead of
failing the post on the first attempt.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: use Laravel Sleep for Pinterest media processing polls
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: inline Pinterest video processing poll constants
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: map Pinterest media upload statuses to an enum
Use the official MediaUploadStatus values (registered, processing,
succeeded, failed) instead of comparing raw strings in the publisher.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: trim Pinterest media processing docblock
* fix: cap platform-unavailable retries and recover stuck retrying posts
Stop infinite reschedules after 6 attempts with a user-safe failure
message, keep technical detail in error_context, recover Retrying
platforms in social:recover-stuck-posts, and drop unused isTerminal().
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: remove unused failedCount in RecoverStuckPosts
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: skip final Pinterest poll sleep and localize recover timeout
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: raise publish job timeout headroom and ignore already-failed platforms
Give social publish jobs 15 minutes so Pinterest media polling fits under
the worker limit, bump Horizon/redis retry_after above that timeout, and
skip handle/failed when the platform is already Failed so delayed jobs
cannot revive posts recovered by social:recover-stuck-posts.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: restore social-publishing and ai-assistant horizon supervisors
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: harden Pinterest 401 handling, unique publish jobs, and recover JSON
Treat media-status 401 as TokenExpired, make PublishToSocialPlatform
unique per platform+attempt so retries still queue, and persist recover
error_context via Eloquent casts instead of manual json_encode.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: mass-update stuck post platforms without per-row each
Eloquent query updates already bind JSON arrays correctly here, so one
UPDATE is enough — no manual json_encode and no N model writes.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: simplify Pinterest media processing poll loop
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: simplify publish job retry and terminal status checks
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: do not finalize posts while platforms are still retrying
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: cover Pinterest timeout, unique jobs, and recover edge cases
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: retry Pinterest media poll on connection errors and tighten tests
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: remove ineffective TypeError import that breaks CI
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 23:49:37 +00:00
|
|
|
|
|
|
|
|
test('pinterest publisher waits through pending video processing before creating the pin', function () {
|
|
|
|
|
Sleep::fake();
|
|
|
|
|
|
|
|
|
|
$this->postPlatform->update(['content_type' => ContentType::PinterestVideoPin]);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'test-media-video',
|
|
|
|
|
'path' => 'media/2026-01/video.mp4',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/video.mp4',
|
|
|
|
|
'mime_type' => 'video/mp4',
|
|
|
|
|
'original_filename' => 'video.mp4',
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$s3UploadUrl = 'https://pinterest-media-upload.s3.amazonaws.com/upload';
|
|
|
|
|
$statusChecks = 0;
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($s3UploadUrl, &$statusChecks) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/media') && $request->method() === 'POST') {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'media_id' => 'media_video_pending',
|
|
|
|
|
'upload_url' => $s3UploadUrl,
|
|
|
|
|
'upload_parameters' => [],
|
|
|
|
|
], 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $s3UploadUrl) {
|
|
|
|
|
return Http::response('', 204);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/media/media_video_pending')) {
|
|
|
|
|
$statusChecks++;
|
|
|
|
|
|
|
|
|
|
return Http::response([
|
|
|
|
|
'status' => $statusChecks < 3 ? 'processing' : 'succeeded',
|
|
|
|
|
], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/pins')) {
|
|
|
|
|
return Http::response(['id' => 'video_pin_after_wait'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-video-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('video_pin_after_wait')
|
|
|
|
|
->and($statusChecks)->toBe(3);
|
|
|
|
|
|
|
|
|
|
Sleep::assertSleptTimes(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher treats video processing timeout as platform unavailable for retry', function () {
|
|
|
|
|
Sleep::fake();
|
|
|
|
|
|
|
|
|
|
$this->postPlatform->update(['content_type' => ContentType::PinterestVideoPin]);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'test-media-video',
|
|
|
|
|
'path' => 'media/2026-01/video.mp4',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/video.mp4',
|
|
|
|
|
'mime_type' => 'video/mp4',
|
|
|
|
|
'original_filename' => 'video.mp4',
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$s3UploadUrl = 'https://pinterest-media-upload.s3.amazonaws.com/upload';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($s3UploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/media') && $request->method() === 'POST') {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'media_id' => 'media_video_timeout',
|
|
|
|
|
'upload_url' => $s3UploadUrl,
|
|
|
|
|
'upload_parameters' => [],
|
|
|
|
|
], 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $s3UploadUrl) {
|
|
|
|
|
return Http::response('', 204);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/media/media_video_timeout')) {
|
|
|
|
|
return Http::response(['status' => 'processing'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-video-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(PlatformUnavailableException::class, 'Pinterest media processing timeout after 60 attempts');
|
|
|
|
|
|
|
|
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/v5/pins'));
|
|
|
|
|
Sleep::assertSleptTimes(59);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher fails hard when video processing reports failed', function () {
|
|
|
|
|
Sleep::fake();
|
|
|
|
|
|
|
|
|
|
$this->postPlatform->update(['content_type' => ContentType::PinterestVideoPin]);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'test-media-video',
|
|
|
|
|
'path' => 'media/2026-01/video.mp4',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/video.mp4',
|
|
|
|
|
'mime_type' => 'video/mp4',
|
|
|
|
|
'original_filename' => 'video.mp4',
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$s3UploadUrl = 'https://pinterest-media-upload.s3.amazonaws.com/upload';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($s3UploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/media') && $request->method() === 'POST') {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'media_id' => 'media_video_failed',
|
|
|
|
|
'upload_url' => $s3UploadUrl,
|
|
|
|
|
'upload_parameters' => [],
|
|
|
|
|
], 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $s3UploadUrl) {
|
|
|
|
|
return Http::response('', 204);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/media/media_video_failed')) {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'status' => 'failed',
|
|
|
|
|
'failure_code' => 'VIDEO_TOO_LONG',
|
|
|
|
|
], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-video-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(PinterestPublishException::class, 'Pinterest media processing failed: VIDEO_TOO_LONG');
|
|
|
|
|
|
|
|
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/v5/pins'));
|
|
|
|
|
Sleep::assertNeverSlept();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher treats media status 401 as token expired', function () {
|
|
|
|
|
Sleep::fake();
|
|
|
|
|
|
|
|
|
|
$this->postPlatform->update(['content_type' => ContentType::PinterestVideoPin]);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'test-media-video',
|
|
|
|
|
'path' => 'media/2026-01/video.mp4',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/video.mp4',
|
|
|
|
|
'mime_type' => 'video/mp4',
|
|
|
|
|
'original_filename' => 'video.mp4',
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$s3UploadUrl = 'https://pinterest-media-upload.s3.amazonaws.com/upload';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($s3UploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/media') && $request->method() === 'POST') {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'media_id' => 'media_video_401',
|
|
|
|
|
'upload_url' => $s3UploadUrl,
|
|
|
|
|
'upload_parameters' => [],
|
|
|
|
|
], 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $s3UploadUrl) {
|
|
|
|
|
return Http::response('', 204);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/media/media_video_401')) {
|
|
|
|
|
return Http::response(['message' => 'Access token has expired or been revoked'], 401);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-video-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(TokenExpiredException::class, 'Access token has expired or been revoked');
|
|
|
|
|
|
|
|
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/v5/pins'));
|
|
|
|
|
Sleep::assertNeverSlept();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher keeps polling through transient media status http errors', function () {
|
|
|
|
|
Sleep::fake();
|
|
|
|
|
|
|
|
|
|
$this->postPlatform->update(['content_type' => ContentType::PinterestVideoPin]);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'test-media-video',
|
|
|
|
|
'path' => 'media/2026-01/video.mp4',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/video.mp4',
|
|
|
|
|
'mime_type' => 'video/mp4',
|
|
|
|
|
'original_filename' => 'video.mp4',
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$s3UploadUrl = 'https://pinterest-media-upload.s3.amazonaws.com/upload';
|
|
|
|
|
$statusChecks = 0;
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($s3UploadUrl, &$statusChecks) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/media') && $request->method() === 'POST') {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'media_id' => 'media_video_5xx',
|
|
|
|
|
'upload_url' => $s3UploadUrl,
|
|
|
|
|
'upload_parameters' => [],
|
|
|
|
|
], 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $s3UploadUrl) {
|
|
|
|
|
return Http::response('', 204);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/media/media_video_5xx')) {
|
|
|
|
|
$statusChecks++;
|
|
|
|
|
|
|
|
|
|
if ($statusChecks < 3) {
|
|
|
|
|
return Http::response(['message' => 'temporary error'], 503);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response(['status' => 'succeeded'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/pins')) {
|
|
|
|
|
return Http::response(['id' => 'video_pin_after_5xx'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-video-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('video_pin_after_5xx')
|
|
|
|
|
->and($statusChecks)->toBe(3);
|
|
|
|
|
|
|
|
|
|
Sleep::assertSleptTimes(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher treats unknown media status as still processing until timeout', function (string $status) {
|
|
|
|
|
Sleep::fake();
|
|
|
|
|
|
|
|
|
|
$this->postPlatform->update(['content_type' => ContentType::PinterestVideoPin]);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'test-media-video',
|
|
|
|
|
'path' => 'media/2026-01/video.mp4',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/video.mp4',
|
|
|
|
|
'mime_type' => 'video/mp4',
|
|
|
|
|
'original_filename' => 'video.mp4',
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$s3UploadUrl = 'https://pinterest-media-upload.s3.amazonaws.com/upload';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($s3UploadUrl, $status) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/media') && $request->method() === 'POST') {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'media_id' => 'media_video_unknown',
|
|
|
|
|
'upload_url' => $s3UploadUrl,
|
|
|
|
|
'upload_parameters' => [],
|
|
|
|
|
], 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $s3UploadUrl) {
|
|
|
|
|
return Http::response('', 204);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/media/media_video_unknown')) {
|
|
|
|
|
return Http::response(['status' => $status], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-video-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(PlatformUnavailableException::class, 'Pinterest media processing timeout after 60 attempts');
|
|
|
|
|
|
|
|
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/v5/pins'));
|
|
|
|
|
Sleep::assertSleptTimes(59);
|
|
|
|
|
})->with([
|
|
|
|
|
'registered',
|
|
|
|
|
'not_a_real_status',
|
|
|
|
|
'',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
test('pinterest publisher treats media status connection errors as platform unavailable', function () {
|
|
|
|
|
Sleep::fake();
|
|
|
|
|
|
|
|
|
|
$this->postPlatform->update(['content_type' => ContentType::PinterestVideoPin]);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'test-media-video',
|
|
|
|
|
'path' => 'media/2026-01/video.mp4',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/video.mp4',
|
|
|
|
|
'mime_type' => 'video/mp4',
|
|
|
|
|
'original_filename' => 'video.mp4',
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$s3UploadUrl = 'https://pinterest-media-upload.s3.amazonaws.com/upload';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($s3UploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/media') && $request->method() === 'POST') {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'media_id' => 'media_video_conn',
|
|
|
|
|
'upload_url' => $s3UploadUrl,
|
|
|
|
|
'upload_parameters' => [],
|
|
|
|
|
], 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $s3UploadUrl) {
|
|
|
|
|
return Http::response('', 204);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/v5/media/media_video_conn')) {
|
|
|
|
|
throw new ConnectionException('cURL error 28: Connection timed out');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-video-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(PlatformUnavailableException::class, 'Pinterest media status check connection failed');
|
|
|
|
|
|
|
|
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/v5/pins'));
|
|
|
|
|
Sleep::assertNeverSlept();
|
|
|
|
|
});
|
Defuse links in X posts to avoid the link-post fee (#308)
X bills a post containing a URL at a much higher rate than a plain post, and
its algorithm demotes link posts. The X version of a post now rewrites every
URL non-clickable (https://example.com/post becomes example(.)com/post):
scheme and www. dropped, every dot of the host replaced with (.).
Leaving a single dot intact would still leave a resolvable domain for X to
detect, so all of them are broken. A scheme or www. proves a token is a URL on
its own; a bare host only counts when its last label is a delegated TLD, which
is the one thing telling acme.com apart from Node.js. That check runs against
App\Support\LinkTlds, generated from the whole IANA root zone in every form a
TLD can appear in a post -- ASCII, punycode and the Unicode it decodes to --
because whatever X links is what X bills, so a hand-picked subset would leave
us paying for its gaps. If the regex engine bails out on pathological input the
original content is returned instead of crashing the publisher.
The transform lives in the Platform::X arm of ContentSanitizer, so it reaches
publishing and the app/API/MCP previews from one place and cannot touch any
other network. Off by default; opt in with X_DEFUSE_LINKS.
The editor counts characters and renders its preview client-side and cannot ask
the server on every keystroke, so the rewrite is mirrored in TypeScript. PHP
stays the source of truth: a parity test fails if the two TLD sets drift, and a
browser test drives the real editor so the mirror is covered rather than
assumed. Without it the composer promised text the network never receives.
Character limits now measure the text a reader will see: sanitized, then with
markup resolved away. Measuring the raw draft blocked saving posts that publish
fine and let through posts the network rejects, and counted the editor's HTML
toward the limit. Measuring the sanitized form alone would have counted
Telegram's escaped entities, rejecting messages Telegram accepts.
Empty content is handled once inside the sanitizer instead of by a guard
repeated at every call site.
2026-08-29 18:31:05 +00:00
|
|
|
|
|
|
|
|
test('pinterest publisher keeps links intact', function () {
|
|
|
|
|
config()->set('trypost.platforms.x.defuse_links', true);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'content' => 'New post: https://acme.com/blog',
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'image.jpg',
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'*/v5/pins' => Http::response(['id' => 'pin_123456'], 200),
|
|
|
|
|
'*' => Http::response('fake-image-content', 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/v5/pins')
|
|
|
|
|
&& $request['description'] === 'New post: https://acme.com/blog');
|
|
|
|
|
});
|