trypost/tests/Unit/Exceptions/Social/SocialPublishExceptionTest.php
Paulo Castellano 4546425532
Resume in-flight Instagram and TikTok publishes without duplicates (#281)
* Improve asynchronous social publishing reliability

* fix: resume asynchronous social publishes

* fix: preserve publish checkpoints across retries

* fix: harden resumable publish lifecycle

* fix: clean retry resources on terminal failures

* test: cover resumable social publishing edge cases

* feat: add failed post retry command

* chore: remove retry command ai rule

* fix: require confirmation for post retries

* chore: remove ai rules index

* chore: remove ai social rule

* refactor: clarify TikTok derivative path validation

* refactor: simplify social publishing retries

* refactor: further simplify social publishing retries

* refactor: retry all failed post platforms

* style: import throwable in social retries

* refactor: decouple TikTok cleanup from image format

* refactor: extract missing publish scopes

* refactor: encapsulate missing scope failure

* fix: resume failed publishes and treat Instagram rate limits as transient

Keep TikTok/Instagram checkpoints on posts:retry so a manual retry does not
start a duplicate remote post. Classify Meta BUC 400s on Instagram status
polls as retryable via GraphError.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: cover resume paths and transient Instagram rate limits

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: resume posts:retry only for in-flight publish failures

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: resume posts:retry via ErrorCategory instead of string lists

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: handle Instagram PUBLISHED and EXPIRED container statuses

Treat EXPIRED as a terminal server error so posts:retry starts over, and complete already-published containers without a second media_publish.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: recover published Instagram stories from /stories

Stories are not on GET /{ig-user-id}/media. Resume a PUBLISHED story container from the stories edge so we do not bind a feed post id.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: cover Instagram EXPIRED retry and published recovery paths

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: stop guessing Instagram media ids from recent /media

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: checkpoint TikTok publish_id and keep in-flight photo derivatives

Persist publish_id right after /init/ so a crash can resume without a second publish. Keep hosted photos while that id is resumable, including token expiry on status fetch; prune only after success or a confirmed remote failure.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: cover remaining TikTok in-flight derivative edge cases

Guard the empty publish_id prune path, account guards without a checkpoint, and video status 401 after /init/.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: map TikTok publish statuses with an official enum

Use PublishStatus for status/fetch values from the Content Posting API. Keep only the documented cases, including FAILED as the terminal failure.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: share in-flight publish checkpoint keys

Read TikTok and Instagram resume state through one helper so publishers, posts:retry, and derivative cleanup agree on the same keys.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: retry Instagram media_publish after transient Graph failures

A 500/code 2 after Meta already published left the job Failed as unknown.
Treat that as still-processing so resume can confirm PUBLISHED instead of posting again.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: resume Instagram publish after dropped Graph connections

A timeout or connection reset after Meta already published was marked unknown.
Treat it as still-processing so resume can confirm PUBLISHED instead of posting again.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 15:34:53 -03:00

114 lines
4.3 KiB
PHP

<?php
declare(strict_types=1);
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\SocialPublishException;
// Concrete implementation for testing the abstract class
class TestPlatformException extends SocialPublishException
{
public static function fromApiResponse(mixed $response): static
{
return new static(
userMessage: 'Test error',
category: ErrorCategory::Unknown,
platformErrorCode: null,
rawResponse: json_encode($response),
);
}
public function platform(): string
{
return 'test_platform';
}
}
test('context returns correct data', function () {
$exception = new TestPlatformException(
userMessage: 'Your image is too large for Instagram.',
category: ErrorCategory::MediaFormat,
platformErrorCode: 'MEDIA_TOO_LARGE',
rawResponse: '{"error": "image too large"}',
);
expect($exception->context())->toBe([
'platform' => 'test_platform',
'category' => 'media_format',
'platform_error_code' => 'MEDIA_TOO_LARGE',
'user_message' => 'Your image is too large for Instagram.',
'raw_response' => '{"error": "image too large"}',
]);
});
test('context returns null for optional fields when not provided', function () {
$exception = new TestPlatformException(
userMessage: 'Something went wrong.',
category: ErrorCategory::Unknown,
);
expect($exception->context())->toBe([
'platform' => 'test_platform',
'category' => 'unknown',
'platform_error_code' => null,
'user_message' => 'Something went wrong.',
'raw_response' => null,
]);
});
test('exception message matches user message', function () {
$exception = new TestPlatformException(
userMessage: 'Rate limit exceeded.',
category: ErrorCategory::RateLimit,
);
expect($exception->getMessage())->toBe('Rate limit exceeded.');
});
test('fromApiResponse creates exception from response', function () {
$exception = TestPlatformException::fromApiResponse(['error' => 'test']);
expect($exception)
->toBeInstanceOf(SocialPublishException::class)
->and($exception->userMessage)->toBe('Test error')
->and($exception->category)->toBe(ErrorCategory::Unknown)
->and($exception->rawResponse)->toBe('{"error":"test"}');
});
test('error category enum has all expected cases', function () {
expect(ErrorCategory::cases())->toHaveCount(10)
->and(ErrorCategory::MediaFormat->value)->toBe('media_format')
->and(ErrorCategory::RateLimit->value)->toBe('rate_limit')
->and(ErrorCategory::Permission->value)->toBe('permission')
->and(ErrorCategory::ContentPolicy->value)->toBe('content_policy')
->and(ErrorCategory::ServerError->value)->toBe('server_error')
->and(ErrorCategory::Unknown->value)->toBe('unknown')
->and(ErrorCategory::PlatformUnavailable->value)->toBe('platform_unavailable')
->and(ErrorCategory::Timeout->value)->toBe('timeout')
->and(ErrorCategory::TokenExpired->value)->toBe('token_expired')
->and(ErrorCategory::JobFailed->value)->toBe('job_failed');
});
test('error category marks only in-flight interruptions as resumable', function (ErrorCategory $category, bool $resumable) {
expect($category->isResumable())->toBe($resumable);
})->with([
'platform unavailable' => [ErrorCategory::PlatformUnavailable, true],
'timeout' => [ErrorCategory::Timeout, true],
'token expired' => [ErrorCategory::TokenExpired, true],
'job failed' => [ErrorCategory::JobFailed, true],
'media format' => [ErrorCategory::MediaFormat, false],
'content policy' => [ErrorCategory::ContentPolicy, false],
'server error' => [ErrorCategory::ServerError, false],
'permission' => [ErrorCategory::Permission, false],
'rate limit' => [ErrorCategory::RateLimit, false],
'unknown' => [ErrorCategory::Unknown, false],
]);
test('error category tryFromContext reads a stored category', function (?array $context, ?ErrorCategory $expected) {
expect(ErrorCategory::tryFromContext($context))->toBe($expected);
})->with([
'resumable' => [['category' => 'token_expired'], ErrorCategory::TokenExpired],
'unknown string' => [['category' => 'not-a-category'], null],
'missing' => [[], null],
'null context' => [null, null],
]);