diff --git a/app/Actions/CreateCommit.php b/app/Actions/CreateCommit.php index 95354559f..ef865acd4 100644 --- a/app/Actions/CreateCommit.php +++ b/app/Actions/CreateCommit.php @@ -11,7 +11,6 @@ use App\Jobs\EmbedCommit; use App\Jobs\ModerateCommit; use App\Jobs\ReplyAsTom; -use App\Jobs\UnfurlLink; use App\Models\Commit; use App\Models\LinkPreview; use App\Models\User; @@ -31,6 +30,7 @@ public function __construct( private CreateMentionNotifications $createMentionNotifications, private CreateCommitImages $createCommitImages, private CreateWorldReply $createWorldReply, + private CreateLinkPreviewJob $createLinkPreviewJob, private UpdateCommitHotScores $updateCommitHotScores, private TheAlgorithm $theAlgorithm, private EmojiShortcodes $emojiShortcodes, @@ -42,8 +42,6 @@ public function __construct( } /** - * The link preview is of the first URL in the body when true, of the given URL when a string, or none when false. - * * @param list $images * @param array $imageDescriptions */ @@ -86,8 +84,10 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $this->createQuoteNotifications->handle($commit); $this->createMentionNotifications->handle($commit); - if ($linkPreview instanceof LinkPreview && $linkPreview->fetched_at === null) { - dispatch(new UnfurlLink($linkPreview)); + if ($linkPreview instanceof LinkPreview && $linkPreview->prepareUnfurlForPublication()) { + DB::afterCommit(function () use ($linkPreview): void { + $this->createLinkPreviewJob->handle($linkPreview); + }); } $this->createWorldReply->handle($commit); @@ -118,9 +118,6 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit }); } - /** - * A poll opens with its commit's approval, so polls of pending commits wait for moderation. - */ private function createPoll(Commit $commit, NewPoll $newPoll): void { $poll = $commit->poll()->create([ @@ -135,9 +132,6 @@ private function createPoll(Commit $commit, NewPoll $newPoll): void )); } - /** - * Commits that link to the same URL share one preview, so a page is fetched once however often it is linked. - */ private function linkPreviewFor(?string $url): ?LinkPreview { if ($url === null) { diff --git a/app/Actions/CreateLinkPreviewJob.php b/app/Actions/CreateLinkPreviewJob.php new file mode 100644 index 000000000..11f8e0803 --- /dev/null +++ b/app/Actions/CreateLinkPreviewJob.php @@ -0,0 +1,31 @@ +fresh(); + + if (! $preview instanceof LinkPreview || $preview->fetched_at !== null) { + return; + } + + $job = new UnfurlLink($preview); + + try { + dispatch($job)->afterCommit(); + } catch (Throwable $throwable) { + $job->failed(); + + throw $throwable; + } + } +} diff --git a/app/Actions/CreateMessage.php b/app/Actions/CreateMessage.php index d45b63fa9..925cde5d2 100644 --- a/app/Actions/CreateMessage.php +++ b/app/Actions/CreateMessage.php @@ -6,7 +6,6 @@ use App\Enums\RemoteStatus; use App\Jobs\SendRemotePush; -use App\Jobs\UnfurlLink; use App\Models\Conversation; use App\Models\ConversationParticipant; use App\Models\LinkPreview; @@ -28,13 +27,11 @@ public function __construct( private CreateMessageVoiceFile $createMessageVoiceFile, private MentionedUsersQuery $mentionedUsers, private Urls $urls, + private CreateLinkPreviewJob $createLinkPreviewJob, ) { // } - /** - * Makes the message the conversation's latest, for every member's inbox. - */ public static function advance(Conversation $conversation, Message $message, bool $unstash): void { Conversation::query() @@ -48,10 +45,6 @@ public static function advance(Conversation $conversation, Message $message, boo } /** - * Sending the same client id again, as a retry does, returns the message it already created, without storing its - * images or voice note twice. A new message brings the conversation back out of the stash for everyone in it. The link card is - * of the first link in the body when true, of the given link when a string, or none when false. - * * @param list $images * @param array $imageDescriptions * @param list $voiceWaveform @@ -108,8 +101,10 @@ public function handle(User $sender, Conversation $conversation, string $body, s ->where('read_through_id', '<', $message->id) ->update(['read_through_id' => $message->id, 'read_at' => now()]); - if ($linkPreview instanceof LinkPreview && $linkPreview->fetched_at === null) { - dispatch(new UnfurlLink($linkPreview))->afterCommit(); + if ($linkPreview instanceof LinkPreview && $linkPreview->prepareUnfurlForPublication()) { + DB::afterCommit(function () use ($linkPreview): void { + $this->createLinkPreviewJob->handle($linkPreview); + }); } return $message; diff --git a/app/Actions/UpdateLinkPreview.php b/app/Actions/UpdateLinkPreview.php index 763c0aba6..8b1451ba0 100644 --- a/app/Actions/UpdateLinkPreview.php +++ b/app/Actions/UpdateLinkPreview.php @@ -38,7 +38,7 @@ public function handle(LinkPreview $linkPreview): void { $metadata = $this->fetchMetadata($linkPreview->url); - $linkPreview->update([...$metadata->toArray(), 'fetched_at' => now()]); + $linkPreview->completeUnfurl($metadata->toArray()); } private function fetchMetadata(string $url): LinkPreviewMetadata diff --git a/app/Http/Controllers/DraftLinkPreviewController.php b/app/Http/Controllers/DraftLinkPreviewController.php new file mode 100644 index 000000000..70c07a9cb --- /dev/null +++ b/app/Http/Controllers/DraftLinkPreviewController.php @@ -0,0 +1,104 @@ +user(); + assert($user instanceof User); + + $url = $request->string('url')->value(); + + Log::info('Draft link preview requested', [ + 'user_id' => $user->id, + 'host' => Url::parse($url)?->getAsciiHost(), + ]); + + if ($embeds->for($url)?->kind === EmbedKind::Gif) { + return $this->response($this->preview($url, new LinkPreviewMetadata)); + } + + $key = "draft-link-unfurls:{$user->id}"; + + return $this->response($this->cachedPreview($url, $key)); + } + + private function cachedPreview(string $url, string $key): LinkPreview + { + $cached = LinkPreview::query()->where('url_hash', hash('sha256', $url))->first(); + + if ($cached instanceof LinkPreview) { + return $cached; + } + + if (RateLimiter::tooManyAttempts($key, self::UNFURLS_PER_MINUTE)) { + throw new TooManyRequestsHttpException(RateLimiter::availableIn($key)); + } + + RateLimiter::increment($key, CarbonInterface::SECONDS_PER_MINUTE); + + return $this->createPreview($url); + } + + private function createPreview(string $url): LinkPreview + { + return DB::transaction(function () use ($url): LinkPreview { + $preview = LinkPreview::query()->firstOrCreate( + ['url_hash' => hash('sha256', $url)], + ['url' => $url, ...(new LinkPreviewMetadata)->toArray()], + ); + + if ($preview->wasRecentlyCreated) { + DB::afterCommit(function () use ($preview): void { + $this->createLinkPreviewJob->handle($preview); + }); + } + + return $preview; + }); + } + + private function response(LinkPreview $preview): JsonResponse + { + return response()->json([ + 'link_preview' => $preview, + ])->header('Cache-Control', 'private, no-store'); + } + + private function preview(string $url, LinkPreviewMetadata $metadata): LinkPreview + { + return new LinkPreview([ + 'url' => $url, + ...$metadata->toArray(), + 'fetched_at' => now(), + ]); + } +} diff --git a/app/Http/Requests/ShowDraftLinkPreviewRequest.php b/app/Http/Requests/ShowDraftLinkPreviewRequest.php new file mode 100644 index 000000000..82026b282 --- /dev/null +++ b/app/Http/Requests/ShowDraftLinkPreviewRequest.php @@ -0,0 +1,26 @@ +> + */ + public function rules(): array + { + return [ + 'url' => ['required', 'string', 'url:http,https', 'max:'.Urls::MAX_LENGTH], + ]; + } +} diff --git a/app/Jobs/UnfurlLink.php b/app/Jobs/UnfurlLink.php index 7ce7f47b2..3bddaa1f5 100644 --- a/app/Jobs/UnfurlLink.php +++ b/app/Jobs/UnfurlLink.php @@ -6,41 +6,59 @@ use App\Actions\UpdateLinkPreview; use App\Models\LinkPreview; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Queue\Attributes\Backoff; use Illuminate\Queue\Attributes\DeleteWhenMissingModels; use Illuminate\Queue\Attributes\Tries; +use Illuminate\Support\Facades\DB; #[Backoff([60, 600])] #[Tries(3)] #[DeleteWhenMissingModels] -final class UnfurlLink implements ShouldQueue +final class UnfurlLink implements ShouldBeUnique, ShouldQueue { use Queueable; + // Allow foreign-key references while serializing workers. + private const string METADATA_LOCK = 'for no key update'; + + // Default supports older queued payloads. + public int $publicationUnfurlRetries = 0; + public function __construct(public readonly LinkPreview $linkPreview) { - // + if ($linkPreview->publication_unfurl_retries === 0) { + return; + } + + $this->publicationUnfurlRetries = $linkPreview->publication_unfurl_retries; + } + + public function uniqueId(): string + { + return $this->linkPreview->id; } - /** - * Commits that share a link may each queue this job before the first one runs, so only the first one fetches. - */ public function handle(UpdateLinkPreview $action): void { - if ($this->linkPreview->fetched_at !== null) { - return; - } + DB::transaction(function () use ($action): void { + $linkPreview = LinkPreview::query()->lock(self::METADATA_LOCK)->find($this->linkPreview->id); + + if (! $linkPreview instanceof LinkPreview + || $linkPreview->fetched_at !== null + || $linkPreview->publication_unfurl_retries !== $this->publicationUnfurlRetries + ) { + return; + } - $action->handle($this->linkPreview); + $action->handle($linkPreview); + }); } - /** - * Once every attempt has failed, the link preview is marked as fetched without metadata, so it is not tried again. - */ public function failed(): void { - $this->linkPreview->update(['fetched_at' => now()]); + $this->linkPreview->markUnfurlAsTransientFailure($this->publicationUnfurlRetries); } } diff --git a/app/Models/LinkPreview.php b/app/Models/LinkPreview.php index 3a5b564d0..cdb2e9a7b 100644 --- a/app/Models/LinkPreview.php +++ b/app/Models/LinkPreview.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\DB; /** * @property-read string $id @@ -22,6 +23,8 @@ * @property-read string|null $site_name * @property-read string|null $gif_url * @property-read CarbonInterface|null $fetched_at + * @property-read CarbonInterface|null $unfurl_failed_at + * @property-read int $publication_unfurl_retries * @property-read CarbonInterface $created_at * @property-read CarbonInterface $updated_at */ @@ -39,6 +42,13 @@ final class LinkPreview extends Model use HasUuids; + public const int MAX_PUBLICATION_UNFURL_RETRIES = 1; + + /** @var array */ + protected $attributes = [ + 'publication_unfurl_retries' => 0, + ]; + /** * @return array */ @@ -54,11 +64,56 @@ public function casts(): array 'site_name' => 'string', 'gif_url' => 'string', 'fetched_at' => 'datetime', + 'unfurl_failed_at' => 'datetime', + 'publication_unfurl_retries' => 'integer', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; } + public function prepareUnfurlForPublication(): bool + { + if ($this->fetched_at === null) { + return true; + } + + return $this->retryTransientFailureForPublication(); + } + + public function retryTransientFailureForPublication(): bool + { + return self::query() + ->whereKey($this->id) + ->whereNotNull('unfurl_failed_at') + ->where('publication_unfurl_retries', '<', self::MAX_PUBLICATION_UNFURL_RETRIES) + ->update([ + 'fetched_at' => null, + 'unfurl_failed_at' => null, + 'publication_unfurl_retries' => DB::raw('publication_unfurl_retries + 1'), + ]) === 1; + } + + /** + * @param array $metadata + */ + public function completeUnfurl(array $metadata): bool + { + return self::query() + ->whereKey($this->id) + ->where('publication_unfurl_retries', $this->publication_unfurl_retries) + ->whereNull('fetched_at') + ->update([...$metadata, 'fetched_at' => now(), 'unfurl_failed_at' => null]) === 1; + } + + public function markUnfurlAsTransientFailure(int $publicationUnfurlRetries): void + { + self::query() + ->whereKey($this->id) + ->where('publication_unfurl_retries', $publicationUnfurlRetries) + ->whereNull('fetched_at') + ->update(['fetched_at' => now(), 'unfurl_failed_at' => now()]); + } + /** * @return array */ diff --git a/database/factories/LinkPreviewFactory.php b/database/factories/LinkPreviewFactory.php index 49d5ccdb2..a61d83a3d 100644 --- a/database/factories/LinkPreviewFactory.php +++ b/database/factories/LinkPreviewFactory.php @@ -50,6 +50,7 @@ public function failed(): self { return $this->state(fn (array $attributes): array => [ 'fetched_at' => now(), + 'unfurl_failed_at' => now(), ]); } } diff --git a/database/migrations/2026_10_20_130000_add_unfurl_state_to_link_previews_table.php b/database/migrations/2026_10_20_130000_add_unfurl_state_to_link_previews_table.php new file mode 100644 index 000000000..a540093d8 --- /dev/null +++ b/database/migrations/2026_10_20_130000_add_unfurl_state_to_link_previews_table.php @@ -0,0 +1,18 @@ +timestamp('unfurl_failed_at')->nullable()->after('fetched_at'); + $table->unsignedTinyInteger('publication_unfurl_retries')->default(0)->after('unfurl_failed_at'); + }); + } +}; diff --git a/routes/web.php b/routes/web.php index 19685915d..532006b7b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -43,6 +43,7 @@ use App\Http\Controllers\ConversationReadController; use App\Http\Controllers\ConversationTypingController; use App\Http\Controllers\DeviceController; +use App\Http\Controllers\DraftLinkPreviewController; use App\Http\Controllers\EmailController; use App\Http\Controllers\EmailDraftController; use App\Http\Controllers\EmailPreviewController; @@ -303,6 +304,9 @@ ->name('commit.store'); Route::post('commits/preview', [CommitPreviewController::class, 'store']) ->name('commit-preview.store'); + Route::get('draft-link-preview', [DraftLinkPreviewController::class, 'show']) + ->middleware('throttle:'.DraftLinkPreviewController::READS_PER_MINUTE.',1,draft-link-preview-reads:') + ->name('draft-link-preview.show'); Route::post('commits/{commit}/translation', [CommitTranslationController::class, 'store']) ->middleware('throttle:10,1,translations:') ->name('commit-translation.store'); diff --git a/tests/Feature/Controllers/ConversationMessageControllerTest.php b/tests/Feature/Controllers/ConversationMessageControllerTest.php index 19327838a..2b0b12b93 100644 --- a/tests/Feature/Controllers/ConversationMessageControllerTest.php +++ b/tests/Feature/Controllers/ConversationMessageControllerTest.php @@ -2,8 +2,10 @@ declare(strict_types=1); +use App\Actions\UpdateLinkPreview; use App\Jobs\UnfurlLink; use App\Models\Conversation; +use App\Models\LinkPreview; use App\Models\Message; use App\Models\MessageVoice; use App\Models\User; @@ -13,6 +15,7 @@ use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -226,6 +229,50 @@ 'no card' => [['link_preview_url' => ''], null], ]); +it('retries a failed draft preview when it is sent in a message', function (): void { + Queue::fake(); + $url = 'https://93.184.216.34/retry'; + Http::fake([$url => Http::response('Back online', 200, ['Content-Type' => 'text/html'])]); + $preview = LinkPreview::factory()->failed()->create(['url' => $url]); + $sender = User::factory()->withRemotes()->create(); + $recipient = User::factory()->withRemotes()->create(); + + $this->actingAs($sender)->postJson(route('conversation-message.store', $recipient), [ + 'body' => 'Look at '.$url, + 'client_id' => Str::uuid()->toString(), + ])->assertCreated() + ->assertJsonPath('message.link_preview.is_pending', true); + + $messagePreview = LinkPreview::query()->findOrFail($preview->id); + + expect($messagePreview) + ->id->toBe($preview->id) + ->fetched_at->toBeNull(); + Queue::assertPushed(UnfurlLink::class, fn (UnfurlLink $job): bool => $job->linkPreview->is($preview)); + + new UnfurlLink($messagePreview)->handle(resolve(UpdateLinkPreview::class)); + + expect($preview->refresh()) + ->title->toBe('Back online') + ->fetched_at->not->toBeNull(); +}); + +it('enqueues a draft preview that is already pending when it is sent in a message', function (): void { + Queue::fake(); + $url = 'https://example.com/pending'; + LinkPreview::factory()->create(['url' => $url]); + $sender = User::factory()->withRemotes()->create(); + $recipient = User::factory()->withRemotes()->create(); + + $this->actingAs($sender)->postJson(route('conversation-message.store', $recipient), [ + 'body' => 'Look at '.$url, + 'client_id' => Str::uuid()->toString(), + ])->assertCreated() + ->assertJsonPath('message.link_preview.is_pending', true); + + Queue::assertPushed(UnfurlLink::class); +}); + it('refuses a link card for a link that is not in the message', function (): void { $mark = User::factory()->withRemotes()->create(); $nuno = User::factory()->withRemotes()->create(); diff --git a/tests/Feature/Controllers/DraftLinkPreviewControllerTest.php b/tests/Feature/Controllers/DraftLinkPreviewControllerTest.php new file mode 100644 index 000000000..bb236150b --- /dev/null +++ b/tests/Feature/Controllers/DraftLinkPreviewControllerTest.php @@ -0,0 +1,421 @@ +app->instance(DnsResolver::class, new readonly class implements DnsResolver + { + private const array HOSTS = [ + 'example.com' => ['93.184.216.34'], + 'www.example.com' => ['93.184.216.35'], + 'giphy.com' => ['104.16.105.123'], + 'mixed.example.com' => ['93.184.216.34', '64:ff9b:2::7f00:1'], + 'nat64.example.com' => ['64:ff9b:2::7f00:1'], + ]; + + public function resolve(string $host): array + { + return self::HOSTS[$host] ?? []; + } + }); +}); + +function draftPreviewUrl(string $url): string +{ + return '/draft-link-preview?'.http_build_query(['url' => $url]); +} + +function fetchQueuedDraftPreview(): LinkPreview +{ + $preview = LinkPreview::query()->sole(); + Queue::assertPushed(UnfurlLink::class, fn (UnfurlLink $job): bool => $job->linkPreview->is($preview)); + new UnfurlLink($preview)->handle(resolve(UpdateLinkPreview::class)); + + return $preview->refresh(); +} + +it('requires authentication', function (): void { + Log::spy(); + $this->getJson(draftPreviewUrl('https://example.com/cat.gif'))->assertUnauthorized(); + + Http::assertNothingSent(); + Queue::assertNothingPushed(); + Log::shouldNotHaveReceived('info'); + expect(LinkPreview::query()->count())->toBe(0); +}); + +it('validates draft link preview URLs before creating cache entries', function (mixed $url): void { + Log::spy(); + $this->actingAs(User::factory()->create()) + ->getJson('/draft-link-preview', ['url' => $url]) + ->assertUnprocessable() + ->assertJsonValidationErrors('url'); + + Http::assertNothingSent(); + Queue::assertNothingPushed(); + Log::shouldNotHaveReceived('info'); + expect(LinkPreview::query()->count())->toBe(0); +})->with([ + 'missing' => null, + 'empty' => '', + 'unsupported protocol' => 'ftp://example.com/cat.gif', + 'too long' => 'https://example.com/'.str_repeat('x', 2048), +]); + +it('returns a Giphy embed without fetching or caching a draft', function (string $host): void { + $url = 'https://'.$host.'/gifs/JIX9t2j0ZTN9S'; + + $this->actingAs(User::factory()->create()) + ->getJson(draftPreviewUrl($url)) + ->assertOk() + ->assertHeader('Cache-Control', 'no-store, private') + ->assertExactJson(['link_preview' => [ + 'url' => $url, + 'title' => null, + 'description' => null, + 'image_url' => null, + 'site_name' => null, + 'is_pending' => false, + 'embed' => [ + 'provider' => 'GIPHY', + 'kind' => 'gif', + 'src' => 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif', + 'aspect_ratio' => null, + 'height' => null, + 'poster' => 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy_s.gif', + ], + ]]); + + Http::assertNothingSent(); + Queue::assertNothingPushed(); + expect(LinkPreview::query()->count())->toBe(0); +})->with(['giphy.com', 'www.giphy.com']); + +it('queues a plain Giphy card when GIF embeds are unavailable', function (array $config): void { + config($config); + $url = 'https://giphy.com/gifs/JIX9t2j0ZTN9S'; + Http::fake([$url => Http::response('Giphy card', 200, ['Content-Type' => 'text/html'])]); + + $this->actingAs(User::factory()->create()) + ->getJson(draftPreviewUrl($url))->assertOk()->assertJsonPath('link_preview.is_pending', true); + + Http::assertNothingSent(); + fetchQueuedDraftPreview(); + $this->getJson(draftPreviewUrl($url)) + ->assertOk() + ->assertJsonPath('link_preview.title', 'Giphy card') + ->assertJsonPath('link_preview.embed', null); + + Http::assertSentCount(1); +})->with([ + 'embeds disabled' => [['embeds.enabled' => false]], + 'Giphy excluded' => [['embeds.only' => ['youtube']]], +]); + +it('queues uncached previews without resolving DNS or publishing content', function (): void { + $this->app->instance(DnsResolver::class, new readonly class implements DnsResolver + { + public function resolve(string $host): array + { + throw new RuntimeException('DNS must run on the queue worker.'); + } + }); + $url = 'https://example.com/cat.gif'; + + $this->actingAs(User::factory()->create()) + ->getJson(draftPreviewUrl($url).'&gif_url=https%3A%2F%2Fevil.example%2Fcat.gif') + ->assertOk() + ->assertExactJson(['link_preview' => [ + 'url' => $url, + 'title' => null, + 'description' => null, + 'image_url' => null, + 'site_name' => null, + 'is_pending' => true, + 'embed' => null, + ]]); + + Http::assertNothingSent(); + $preview = LinkPreview::query()->sole(); + expect($preview->url_hash)->toBe(hash('sha256', $url)) + ->and($preview->gif_url)->toBeNull() + ->and(Commit::query()->count())->toBe(0); + Queue::assertPushed(UnfurlLink::class, fn (UnfurlLink $job): bool => $job->linkPreview->is($preview)); +}); + +it('returns verified GIFs from the shared cache after the queued fetch', function (bool $redirect): void { + $source = 'https://example.com/cat.gif'; + $final = $redirect ? 'https://www.example.com/media?id=cat' : $source; + Http::fake([ + $source => $redirect + ? Http::response(status: 302, headers: ['Location' => $final]) + : Http::response("GIF89a\x01\x00\x01\x00\x80\x00\x00", 206, ['Content-Type' => 'image/gif']), + ...($redirect ? [$final => Http::response("GIF89a\x01\x00\x01\x00\x80\x00\x00", 206, ['Content-Type' => 'image/gif'])] : []), + ]); + + $this->actingAs(User::factory()->create()) + ->getJson(draftPreviewUrl($source))->assertOk()->assertJsonPath('link_preview.is_pending', true); + Http::assertNothingSent(); + fetchQueuedDraftPreview(); + + $this->getJson(draftPreviewUrl($source)) + ->assertOk() + ->assertJsonPath('link_preview.url', $source) + ->assertJsonPath('link_preview.is_pending', false) + ->assertJsonPath('link_preview.embed.provider', 'GIF') + ->assertJsonPath('link_preview.embed.kind', 'gif') + ->assertJsonPath('link_preview.embed.src', $final) + ->assertJsonPath('link_preview.embed.aspect_ratio', null); + + Http::assertSentCount($redirect ? 2 : 1); + Queue::assertPushed(UnfurlLink::class, 1); + expect(LinkPreview::query()->count())->toBe(1); +})->with(['direct' => false, 'extensionless redirect' => true]); + +it('does not create an embed when the queued GIF check fails', function (): void { + $url = 'https://example.com/cat.gif'; + Http::fake([$url => Http::response('Not a GIF', 200, ['Content-Type' => 'image/gif'])]); + + $this->actingAs(User::factory()->create())->getJson(draftPreviewUrl($url))->assertOk(); + fetchQueuedDraftPreview(); + $this->getJson(draftPreviewUrl($url)) + ->assertOk()->assertJsonPath('link_preview.is_pending', false)->assertJsonPath('link_preview.embed', null); +}); + +it('keeps the shared address guard on queued draft fetches', function (string $host): void { + $url = 'https://'.$host.'/cat.gif'; + $this->actingAs(User::factory()->create())->getJson(draftPreviewUrl($url))->assertOk(); + fetchQueuedDraftPreview(); + + $this->getJson(draftPreviewUrl($url)) + ->assertOk()->assertJsonPath('link_preview.is_pending', false)->assertJsonPath('link_preview.embed', null); + Http::assertNothingSent(); +})->with(['mixed.example.com', 'nat64.example.com']); + +it('does not follow queued redirects to an unsafe DNS destination', function (): void { + $url = 'https://example.com/cat.gif'; + Http::fake([$url => Http::response(status: 302, headers: ['Location' => 'https://nat64.example.com/cat.gif'])]); + + $this->actingAs(User::factory()->create())->getJson(draftPreviewUrl($url))->assertOk(); + fetchQueuedDraftPreview(); + $this->getJson(draftPreviewUrl($url))->assertOk()->assertJsonPath('link_preview.embed', null); + Http::assertSentCount(1); +}); + +it('leaves a transient failure pending for the existing job retry policy', function (): void { + $url = 'https://example.com/cat.gif'; + Http::fake([$url => Http::failedConnection()]); + + $this->actingAs(User::factory()->create())->getJson(draftPreviewUrl($url))->assertOk(); + expect(fn (): LinkPreview => fetchQueuedDraftPreview())->toThrow(ConnectionException::class); + $this->getJson(draftPreviewUrl($url))->assertOk()->assertJsonPath('link_preview.is_pending', true); + Queue::assertPushed(UnfurlLink::class, 1); + Http::assertSentCount(1); +}); + +it('reuses completed metadata without an independent expiry policy', function (int $ageInDays): void { + $preview = LinkPreview::factory()->fetched()->create([ + 'url' => 'https://example.com/cat.gif', + 'gif_url' => 'https://www.example.com/media?id=cat', + 'fetched_at' => now()->subDays($ageInDays), + ]); + $original = $preview->refresh()->getAttributes(); + + $this->actingAs(User::factory()->create()) + ->getJson(draftPreviewUrl($preview->url)) + ->assertOk() + ->assertJsonPath('link_preview.title', $preview->title) + ->assertJsonPath('link_preview.embed.src', $preview->gif_url) + ->assertJsonPath('link_preview.is_pending', false); + + Http::assertNothingSent(); + Queue::assertNothingPushed(); + expect($preview->fresh()->getAttributes())->toBe($original) + ->and(LinkPreview::query()->count())->toBe(1); +})->with([0, 30]); + +it('does not retry a terminal empty cache row just because a draft polls it', function (): void { + $preview = LinkPreview::factory()->failed()->create(['url' => 'https://example.com/unavailable']); + $this->actingAs(User::factory()->create()); + + foreach (range(1, 7) as $attempt) { + $this->getJson(draftPreviewUrl($preview->url)) + ->assertOk() + ->assertJsonPath('link_preview.is_pending', false) + ->assertJsonPath('link_preview.title', null) + ->assertJsonPath('link_preview.embed', null); + } + + Queue::assertNothingPushed(); + Http::assertNothingSent(); +}); + +it('reuses pending rows and polling never enqueues duplicate work', function (): void { + $url = 'https://example.com/article'; + $this->actingAs(User::factory()->create()); + foreach (range(1, 7) as $attempt) { + $this->getJson(draftPreviewUrl($url))->assertOk()->assertJsonPath('link_preview.is_pending', true); + } + + Queue::assertPushed(UnfurlLink::class, 1); + Http::assertNothingSent(); + expect(LinkPreview::query()->count())->toBe(1); +}); + +it('dispatches only after its cache transaction commits', function (): void { + $transactionLevel = DB::transactionLevel(); + Bus::shouldReceive('dispatch')->once() + ->withArgs(fn (UnfurlLink $job): bool => DB::transactionLevel() === $transactionLevel) + ->andReturnNull(); + + $this->actingAs(User::factory()->create()) + ->getJson(draftPreviewUrl('https://example.com/article'))->assertOk(); +}); + +it('ends the pending state when the real queue driver cannot enqueue the job', function (): void { + config(['queue.default' => 'database']); + Queue::swap(Queue::fake()->queue); + DB::connection()->beforeExecuting(function (string $query): void { + throw_if(str_starts_with($query, 'insert into "jobs"'), RuntimeException::class, 'Queue unavailable'); + }); + $this->withoutExceptionHandling(); + $user = User::factory()->create(); + $this->actingAs($user); + + expect(fn () => $this->getJson(draftPreviewUrl('https://example.com/article'))) + ->toThrow(RuntimeException::class, 'Queue unavailable'); + + expect(LinkPreview::query()->sole()->fetched_at)->not->toBeNull() + ->and(DB::table('jobs')->count())->toBe(0) + ->and(RateLimiter::attempts('draft-link-unfurls:'.$user->id))->toBe(1); + Http::assertNothingSent(); +}); + +it('runs an uncached preview on the database queue and returns it on polling', function (): void { + config(['queue.default' => 'database']); + Queue::swap(Queue::fake()->queue); + $url = 'https://example.com/article'; + Http::fake([$url => Http::response('Queued article', 200, ['Content-Type' => 'text/html'])]); + + $this->actingAs(User::factory()->create()) + ->getJson(draftPreviewUrl($url))->assertOk()->assertJsonPath('link_preview.is_pending', true); + Http::assertNothingSent(); + + expect(DB::table('jobs')->count())->toBe(1); + $this->artisan('queue:work', ['connection' => 'database', '--once' => true])->assertSuccessful(); + expect(DB::table('failed_jobs')->count())->toBe(0); + Http::assertSentCount(1); + $this->getJson(draftPreviewUrl($url)) + ->assertOk()->assertJsonPath('link_preview.title', 'Queued article')->assertJsonPath('link_preview.is_pending', false); + Http::assertSentCount(1); +}); + +it('looks up only the requested URL hash', function (): void { + LinkPreview::factory()->fetched()->create(['url' => 'https://example.com/other']); + $url = 'https://example.com/article'; + $this->actingAs(User::factory()->create()) + ->getJson(draftPreviewUrl($url))->assertOk()->assertJsonPath('link_preview.is_pending', true); + expect(LinkPreview::query()->count())->toBe(2); + Queue::assertPushed(UnfurlLink::class, fn (UnfurlLink $job): bool => $job->linkPreview->url === $url); + Http::assertNothingSent(); +}); + +it('records the authenticated user and host without logging draft URL secrets', function (): void { + Log::spy(); + $user = User::factory()->create(); + $this->actingAs($user) + ->getJson(draftPreviewUrl('https://example.com/cat.gif?token=private-draft-token'))->assertOk(); + Log::shouldHaveReceived('info')->once()->with('Draft link preview requested', [ + 'user_id' => $user->id, + 'host' => 'example.com', + ]); + Http::assertNothingSent(); +}); + +it('reuses the fetched draft metadata when the URL is published', function (): void { + $url = 'https://example.com/cat.gif'; + Http::fake([$url => Http::response("GIF89a\x01\x00\x01\x00\x80\x00\x00", 206, ['Content-Type' => 'image/gif'])]); + $user = User::factory()->create(); + $this->actingAs($user)->getJson(draftPreviewUrl($url))->assertOk(); + $preview = fetchQueuedDraftPreview(); + Queue::fake(); + + $commit = resolve(CreateCommit::class)->handle($user, 'Look at '.$url); + expect($commit->link_preview_id)->toBe($preview->id) + ->and(LinkPreview::query()->count())->toBe(1); + Queue::assertNotPushed(UnfurlLink::class); + Http::assertSentCount(1); +}); + +it('limits new unfurls without charging cached polls to that budget', function (): void { + $this->actingAs(User::factory()->create()); + foreach (range(1, 10) as $attempt) { + $this->getJson(draftPreviewUrl('https://example.com/article/'.$attempt))->assertOk(); + } + + $blocked = 'https://example.com/another'; + $this->getJson(draftPreviewUrl($blocked))->assertTooManyRequests()->assertHeader('Retry-After'); + expect(LinkPreview::query()->count())->toBe(10); + + foreach (range(1, 7) as $attempt) { + $this->getJson(draftPreviewUrl('https://example.com/article/1'))->assertOk(); + } + + Queue::assertPushed(UnfurlLink::class, 10); + $this->actingAs(User::factory()->create())->getJson(draftPreviewUrl($blocked))->assertOk(); + Queue::assertPushed(UnfurlLink::class, 11); + Http::assertNothingSent(); +}); + +it('allows two pending cards to complete their polling cycles in one minute', function (): void { + $this->actingAs(User::factory()->create()); + foreach (['https://example.com/first', 'https://example.com/second'] as $url) { + foreach (range(1, 7) as $attempt) { + $this->getJson(draftPreviewUrl($url))->assertOk()->assertJsonPath('link_preview.is_pending', true); + } + } + + Queue::assertPushed(UnfurlLink::class, 2); + Http::assertNothingSent(); +}); + +it('allows new unfurls again after the work budget expires', function (): void { + $this->actingAs(User::factory()->create()); + foreach (range(1, 10) as $attempt) { + $this->getJson(draftPreviewUrl('https://example.com/article/'.$attempt))->assertOk(); + } + + $url = 'https://example.com/after-limit'; + $this->getJson(draftPreviewUrl($url))->assertTooManyRequests(); + $this->travel(61)->seconds(); + $this->getJson(draftPreviewUrl($url))->assertOk(); + Queue::assertPushed(UnfurlLink::class, 11); +}); + +it('limits preview reads separately from new work', function (): void { + $this->actingAs(User::factory()->create()); + $url = draftPreviewUrl('https://giphy.com/gifs/JIX9t2j0ZTN9S'); + foreach (range(1, 120) as $attempt) { + $this->getJson($url)->assertOk(); + } + + $this->getJson($url)->assertTooManyRequests(); + Queue::assertNothingPushed(); + Http::assertNothingSent(); +}); diff --git a/tests/Fixtures/unfurl-link.php b/tests/Fixtures/unfurl-link.php new file mode 100644 index 000000000..f1297a648 --- /dev/null +++ b/tests/Fixtures/unfurl-link.php @@ -0,0 +1,50 @@ +make(Kernel::class)->bootstrap(); + +$preview = LinkPreview::query()->findOrFail($argv[1]); +$directory = $argv[2]; +$isFirst = $argv[3] === 'first'; +$job = new UnfurlLink($preview); +$pid = DB::selectOne('select pg_backend_pid() as pid')->pid; +fwrite(STDOUT, "PID:{$pid}\n"); + +Http::preventStrayRequests(); +Http::fake(function () use ($directory, $isFirst) { + file_put_contents($directory.'/requests', "request\n", FILE_APPEND | LOCK_EX); + + if ($isFirst) { + fwrite(STDOUT, "FETCHING\n"); + $deadline = microtime(true) + RELEASE_TIMEOUT_SECONDS; + + while (! is_file($directory.'/release')) { + throw_if(microtime(true) > $deadline, RuntimeException::class, 'Concurrent unfurl fixture was not released.'); + + Sleep::usleep(POLL_INTERVAL_MICROSECONDS); + } + } + + return Http::response('Concurrent preview', 200, ['Content-Type' => 'text/html']); +}); + +$job->handle($app->make(UpdateLinkPreview::class)); diff --git a/tests/Unit/Actions/CreateCommitTest.php b/tests/Unit/Actions/CreateCommitTest.php index 7ed3b12bf..733a62494 100644 --- a/tests/Unit/Actions/CreateCommitTest.php +++ b/tests/Unit/Actions/CreateCommitTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Actions\CreateCommit; +use App\Actions\UpdateLinkPreview; use App\Dto\NewPoll; use App\Dto\ThreadEngagement; use App\Enums\CommitStatus; @@ -19,6 +20,7 @@ use App\Services\TheAlgorithm; use Illuminate\Database\QueryException; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; @@ -344,6 +346,85 @@ Queue::assertNotPushed(UnfurlLink::class); }); +it('keeps a definitive empty draft preview cached when it is published', function (): void { + Queue::fake(); + $url = 'https://93.184.216.34/retry'; + $preview = LinkPreview::factory()->create(['url' => $url]); + Http::fakeSequence('https://93.184.216.34/*') + ->push('', 404); + new UnfurlLink($preview)->handle(resolve(UpdateLinkPreview::class)); + expect($preview->refresh()) + ->title->toBeNull() + ->fetched_at->not->toBeNull() + ->unfurl_failed_at->toBeNull(); + Http::assertSentCount(1); + + $commit = resolve(CreateCommit::class)->handle(User::factory()->create(), 'Look at '.$url); + + expect($commit->linkPreview) + ->id->toBe($preview->id) + ->fetched_at->not->toBeNull() + ->toArray()->is_pending->toBeFalse(); + Queue::assertNotPushed(UnfurlLink::class); + Http::assertSentCount(1); +}); + +it('retries a transient draft preview only once when it is published', function (): void { + Queue::fake(); + $preview = LinkPreview::factory()->failed()->create(['url' => 'https://example.com/retry']); + $action = resolve(CreateCommit::class); + + $action->handle(User::factory()->create(), 'Look at '.$preview->url); + + expect($preview->refresh()) + ->fetched_at->toBeNull() + ->publication_unfurl_retries->toBe(1); + Queue::assertPushed(UnfurlLink::class, 1); + + $preview->markUnfurlAsTransientFailure(1); + $action->handle(User::factory()->create(), 'Look at '.$preview->url); + + expect($preview->refresh()) + ->fetched_at->not->toBeNull() + ->publication_unfurl_retries->toBe(1); + Queue::assertPushed(UnfurlLink::class, 1); +}); + +it('queues a new link preview after the commit transaction commits', function (): void { + Queue::fake(); + + resolve(CreateCommit::class)->handle(User::factory()->create(), 'Look at https://example.com/new'); + + Queue::assertPushed(UnfurlLink::class, 1); + Queue::assertPushed(UnfurlLink::class, fn (UnfurlLink $job): bool => $job->afterCommit === true); +}); + +it('enqueues a draft preview that is already pending when it is published', function (): void { + Queue::fake(); + LinkPreview::factory()->create(['url' => 'https://example.com/pending']); + + resolve(CreateCommit::class)->handle(User::factory()->create(), 'Look at https://example.com/pending'); + + Queue::assertPushed(UnfurlLink::class); +}); + +it('keeps completed GIF preview metadata when it is published', function (): void { + Queue::fake(); + $preview = LinkPreview::factory()->create([ + 'url' => 'https://example.com/cat.gif', + 'gif_url' => 'https://cdn.example.com/cat.gif', + 'fetched_at' => now(), + ]); + + $commit = resolve(CreateCommit::class)->handle(User::factory()->create(), 'Look at https://example.com/cat.gif'); + + expect($commit->linkPreview) + ->id->toBe($preview->id) + ->gif_url->toBe('https://cdn.example.com/cat.gif') + ->fetched_at->not->toBeNull(); + Queue::assertNotPushed(UnfurlLink::class); +}); + it('queues the link preview for the saved commit', function (): void { Queue::fake(); diff --git a/tests/Unit/Actions/CreateLinkPreviewJobTest.php b/tests/Unit/Actions/CreateLinkPreviewJobTest.php new file mode 100644 index 000000000..a5732000b --- /dev/null +++ b/tests/Unit/Actions/CreateLinkPreviewJobTest.php @@ -0,0 +1,44 @@ +fetched()->create(); + + resolve(CreateLinkPreviewJob::class)->handle($preview); + + Queue::assertNothingPushed(); +}); + +it('does not queue a preview deleted before its dispatch callback runs', function (): void { + Queue::fake(); + $preview = LinkPreview::factory()->create(); + $preview->delete(); + + resolve(CreateLinkPreviewJob::class)->handle($preview); + + Queue::assertNothingPushed(); +}); + +it('records a transient failure when the queue driver rejects a preview', function (): void { + config(['queue.default' => 'database']); + Queue::swap(Queue::fake()->queue); + DB::connection()->beforeExecuting(function (string $query): void { + throw_if(str_starts_with($query, 'insert into "jobs"'), RuntimeException::class, 'Queue unavailable'); + }); + $preview = LinkPreview::factory()->create(); + + expect(fn () => resolve(CreateLinkPreviewJob::class)->handle($preview)) + ->toThrow(RuntimeException::class, 'Queue unavailable'); + + expect($preview->refresh()) + ->fetched_at->not->toBeNull() + ->unfurl_failed_at->not->toBeNull() + ->publication_unfurl_retries->toBe(0); +}); diff --git a/tests/Unit/Jobs/UnfurlLinkConcurrencyTest.php b/tests/Unit/Jobs/UnfurlLinkConcurrencyTest.php new file mode 100644 index 000000000..1c037b412 --- /dev/null +++ b/tests/Unit/Jobs/UnfurlLinkConcurrencyTest.php @@ -0,0 +1,89 @@ +array('database.connections.'.$connection); + config()->set('database.connections.unfurl_concurrency', $settings); + $fixtures = DB::connection('unfurl_concurrency'); + $id = (string) Str::uuid(); + $url = 'https://93.184.216.34/concurrent/'.$id; + $directory = sys_get_temp_dir().'/unfurl-concurrency-'.$id; + File::makeDirectory($directory); + + // A separate connection commits this fixture so both worker processes can see it. + $fixtures->table('link_previews')->insert([ + 'id' => $id, + 'url' => $url, + 'url_hash' => hash('sha256', $url), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $environment = [ + 'APP_ENV' => 'testing', + 'DB_CONNECTION' => $connection, + 'DB_URL' => '', + 'DB_HOST' => config()->string('database.connections.'.$connection.'.host'), + 'DB_PORT' => (string) config('database.connections.'.$connection.'.port'), + 'DB_DATABASE' => config()->string('database.connections.'.$connection.'.database'), + 'DB_USERNAME' => config()->string('database.connections.'.$connection.'.username'), + 'DB_PASSWORD' => config()->string('database.connections.'.$connection.'.password'), + 'CACHE_STORE' => 'array', + ]; + $fixture = base_path('tests/Fixtures/unfurl-link.php'); + $first = new Process([PHP_BINARY, $fixture, $id, $directory, 'first'], base_path(), $environment); + $second = new Process([PHP_BINARY, $fixture, $id, $directory, 'second'], base_path(), $environment); + + try { + Sleep::fake(false); + $first->setTimeout(UNFURL_CONCURRENCY_TIMEOUT_SECONDS)->start(); + $first->waitUntil(static fn (string $type, string $output): bool => $type === Process::OUT && str_contains($output, 'FETCHING')); + expect($fixtures->selectOne('select id from link_previews where id = ? for key share nowait', [$id])?->id)->toBe($id); + $second->setTimeout(UNFURL_CONCURRENCY_TIMEOUT_SECONDS)->start(); + $second->waitUntil(static fn (string $type, string $output): bool => $type === Process::OUT && str_contains($output, 'PID:')); + throw_unless(preg_match('/PID:(\d+)/', $second->getOutput(), $match) === 1, RuntimeException::class, 'Second worker did not report its database session.'); + + $deadline = microtime(true) + UNFURL_CONCURRENCY_TIMEOUT_SECONDS; + $isWaiting = false; + + while ($second->isRunning() && microtime(true) < $deadline) { + $isWaiting = $fixtures->selectOne('select wait_event_type from pg_stat_activity where pid = ?', [(int) $match[1]])?->wait_event_type === 'Lock'; + + if ($isWaiting) { + break; + } + + Sleep::usleep(UNFURL_LOCK_POLL_MICROSECONDS); + } + + touch($directory.'/release'); + $first->wait(); + $second->wait(); + + expect($first->isSuccessful())->toBeTrue($first->getErrorOutput()) + ->and($second->isSuccessful())->toBeTrue($second->getErrorOutput()) + ->and(file_get_contents($directory.'/requests'))->toBe("request\n") + ->and($isWaiting)->toBeTrue() + ->and(LinkPreview::query()->findOrFail($id)->title)->toBe('Concurrent preview'); + } finally { + Sleep::fake(); + touch($directory.'/release'); + $first->stop(); + $second->stop(); + $fixtures->table('link_previews')->where('id', $id)->delete(); + DB::purge('unfurl_concurrency'); + File::deleteDirectory($directory); + } +}); diff --git a/tests/Unit/Jobs/UnfurlLinkTest.php b/tests/Unit/Jobs/UnfurlLinkTest.php index c859be632..7673d0a04 100644 --- a/tests/Unit/Jobs/UnfurlLinkTest.php +++ b/tests/Unit/Jobs/UnfurlLinkTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use App\Actions\UpdateLinkPreview; +use App\Contracts\Services\DnsResolver; use App\Jobs\UnfurlLink; use App\Models\LinkPreview; use Illuminate\Support\Facades\DB; @@ -38,6 +40,26 @@ function runNextQueuedJob(): void expect($linkPreview->refresh()->title)->toBe('Fetched before'); }); +it('keeps a blocked address as a definitive empty preview', function (): void { + $this->app->instance(DnsResolver::class, new readonly class implements DnsResolver + { + /** @return list */ + public function resolve(string $host): array + { + return ['10.0.0.1']; + } + }); + $linkPreview = LinkPreview::factory()->create(['url' => 'https://blocked.example.com/']); + + new UnfurlLink($linkPreview)->handle(resolve(UpdateLinkPreview::class)); + + expect($linkPreview->refresh()) + ->fetched_at->not->toBeNull() + ->unfurl_failed_at->toBeNull() + ->and($linkPreview->prepareUnfurlForPublication())->toBeFalse(); + Http::assertNothingSent(); +}); + it('retries a page that may be reachable later', function (): void { Exceptions::fake(); @@ -87,11 +109,51 @@ function runNextQueuedJob(): void expect($linkPreview->refresh()) ->title->toBeNull() ->fetched_at->not->toBeNull() + ->unfurl_failed_at->not->toBeNull() ->and(DB::table('jobs')->count())->toBe(0); Http::assertSentCount(3); }); +it('queues one job for a preview while its retries are pending', function (): void { + Queue::fake(); + $linkPreview = LinkPreview::factory()->create(); + + dispatch(new UnfurlLink($linkPreview)); + dispatch(new UnfurlLink($linkPreview)); + + Queue::assertPushed(UnfurlLink::class, 1); +}); + +it('does not let a stale failed callback overwrite a completed publication retry', function (): void { + Http::fake(['https://93.184.216.34/*' => Http::response('Recovered', 200, ['Content-Type' => 'text/html'])]); + $linkPreview = LinkPreview::factory()->create(['url' => 'https://93.184.216.34/']); + $staleJob = new UnfurlLink($linkPreview); + + $linkPreview->markUnfurlAsTransientFailure(0); + $retry = LinkPreview::query()->findOrFail($linkPreview->id); + $retry->retryTransientFailureForPublication(); + + new UnfurlLink(LinkPreview::query()->findOrFail($linkPreview->id))->handle(resolve(UpdateLinkPreview::class)); + $staleJob->failed(); + + expect($linkPreview->refresh()) + ->title->toBe('Recovered') + ->fetched_at->not->toBeNull() + ->unfurl_failed_at->toBeNull() + ->publication_unfurl_retries->toBe(1); +}); + +it('keeps old queued job payloads compatible with the first unfurl cycle', function (): void { + $job = new UnfurlLink(LinkPreview::factory()->create()); + $payload = $job->__serialize(); + unset($payload['publicationUnfurlRetries']); + $legacyJob = new ReflectionClass(UnfurlLink::class)->newInstanceWithoutConstructor(); + $legacyJob->__unserialize($payload); + + expect($legacyJob->publicationUnfurlRetries)->toBe(0); +}); + it('is dropped when its link preview is gone', function (): void { Queue::connection('database')->push(new UnfurlLink(LinkPreview::factory()->create())); diff --git a/tests/Unit/Models/LinkPreviewTest.php b/tests/Unit/Models/LinkPreviewTest.php index fc5b061e2..2cf3d12d6 100644 --- a/tests/Unit/Models/LinkPreviewTest.php +++ b/tests/Unit/Models/LinkPreviewTest.php @@ -27,6 +27,41 @@ ->and(LinkPreview::factory()->failed()->create()->toArray()['is_pending'])->toBeFalse(); }); +it('lets only one stale instance start the publication retry', function (): void { + $preview = LinkPreview::factory()->failed()->create(); + $first = LinkPreview::query()->findOrFail($preview->id); + $second = LinkPreview::query()->findOrFail($preview->id); + + expect($first->prepareUnfurlForPublication())->toBeTrue() + ->and($second->prepareUnfurlForPublication())->toBeFalse() + ->and($preview->refresh()) + ->fetched_at->toBeNull() + ->publication_unfurl_retries->toBe(1); +}); + +it('distinguishes definitive completion from a recorded transient failure', function (): void { + $completed = LinkPreview::factory()->create(); + $completed->completeUnfurl(['title' => null]); + + $transientFailure = LinkPreview::factory()->failed()->create(); + + expect($completed->fresh()->prepareUnfurlForPublication())->toBeFalse() + ->and($transientFailure->prepareUnfurlForPublication())->toBeTrue(); +}); + +it('does not let an old completion overwrite a newer publication retry', function (): void { + $preview = LinkPreview::factory()->create(); + $oldCycle = LinkPreview::query()->findOrFail($preview->id); + $oldCycle->markUnfurlAsTransientFailure(0); + + $retry = LinkPreview::query()->findOrFail($preview->id); + $retry->prepareUnfurlForPublication(); + + expect($oldCycle->completeUnfurl(['title' => 'Old']))->toBeFalse() + ->and(LinkPreview::query()->findOrFail($preview->id)->completeUnfurl(['title' => 'New']))->toBeTrue() + ->and($preview->refresh()->title)->toBe('New'); +}); + it('has an embed when its link can be played in place', function (): void { expect(LinkPreview::factory()->create(['url' => 'https://youtu.be/dQw4w9WgXcQ'])->toArray()['embed'])->toMatchArray([ 'provider' => 'YouTube',