From 2585d89cb445a4ba7df625e1dbd1448a46f564bc Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 13 Jun 2026 22:38:23 -0300 Subject: [PATCH] Address PR review: connect-status enum, publisher cleanup, fill test gaps --- .../SocialAccount/TelegramConnectStatus.php | 28 +++++++++ .../Social/TelegramPublishException.php | 2 +- .../Controllers/Auth/TelegramController.php | 12 ++-- .../Webhooks/TelegramWebhookController.php | 2 +- app/Services/Social/TelegramPublisher.php | 38 ++++------- .../accounts/TelegramConnectDialog.vue | 35 ++++++----- .../Services/Social/TelegramPublisherTest.php | 49 +++++++++++++++ .../Feature/Social/TelegramConnectionTest.php | 44 +++++++++++++ .../Social/TelegramPublishExceptionTest.php | 63 +++++++++++++++++++ 9 files changed, 224 insertions(+), 49 deletions(-) create mode 100644 app/Enums/SocialAccount/TelegramConnectStatus.php create mode 100644 tests/Unit/Exceptions/Social/TelegramPublishExceptionTest.php diff --git a/app/Enums/SocialAccount/TelegramConnectStatus.php b/app/Enums/SocialAccount/TelegramConnectStatus.php new file mode 100644 index 00000000..3afeb0bc --- /dev/null +++ b/app/Enums/SocialAccount/TelegramConnectStatus.php @@ -0,0 +1,28 @@ + self::Unknown, + $request->social_account_id !== null => self::Connected, + $request->isExpired() => self::Expired, + default => self::Pending, + }; + } +} diff --git a/app/Exceptions/Social/TelegramPublishException.php b/app/Exceptions/Social/TelegramPublishException.php index 26943e1a..56675239 100644 --- a/app/Exceptions/Social/TelegramPublishException.php +++ b/app/Exceptions/Social/TelegramPublishException.php @@ -55,7 +55,7 @@ public static function fromApiResponse(mixed $response): static return new static( userMessage: $description, - category: ErrorCategory::ContentPolicy, + category: ErrorCategory::Unknown, platformErrorCode: (string) $status, rawResponse: $rawResponse, ); diff --git a/app/Http/Controllers/Auth/TelegramController.php b/app/Http/Controllers/Auth/TelegramController.php index fb146860..9e7d7530 100644 --- a/app/Http/Controllers/Auth/TelegramController.php +++ b/app/Http/Controllers/Auth/TelegramController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Auth; use App\Enums\SocialAccount\Platform as SocialPlatform; +use App\Enums\SocialAccount\TelegramConnectStatus; use App\Models\TelegramConnectRequest; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -56,13 +57,8 @@ public function status(Request $request): JsonResponse ->where('code', (string) $request->query('code')) ->first(); - $status = match (true) { - $connectRequest === null => 'unknown', - $connectRequest->social_account_id !== null => 'connected', - $connectRequest->isExpired() => 'expired', - default => 'pending', - }; - - return response()->json(['status' => $status]); + return response()->json([ + 'status' => TelegramConnectStatus::for($connectRequest)->value, + ]); } } diff --git a/app/Http/Controllers/Webhooks/TelegramWebhookController.php b/app/Http/Controllers/Webhooks/TelegramWebhookController.php index c394dade..1ebbb066 100644 --- a/app/Http/Controllers/Webhooks/TelegramWebhookController.php +++ b/app/Http/Controllers/Webhooks/TelegramWebhookController.php @@ -56,7 +56,7 @@ public function handle(Request $request): Response ], [ 'username' => $username, - 'display_name' => data_get($chat, 'title') ?? $username, + 'display_name' => data_get($chat, 'title') ?? $username ?? "Telegram {$chatId}", 'access_token' => '', 'refresh_token' => '', 'token_expires_at' => null, diff --git a/app/Services/Social/TelegramPublisher.php b/app/Services/Social/TelegramPublisher.php index 24e79e24..8772fda5 100644 --- a/app/Services/Social/TelegramPublisher.php +++ b/app/Services/Social/TelegramPublisher.php @@ -104,37 +104,25 @@ private function sendSingleMedia(string $chatId, array $item, string $caption): */ private function sendMediaGroup(string $chatId, array $items, string $caption): int { - $firstMessageId = 0; + $group = []; - foreach (array_chunk($items, self::ALBUM_CHUNK) as $chunkIndex => $chunk) { - $group = []; + foreach ($items as $index => $item) { + $entry = ['type' => $item['type'], 'media' => $item['url']]; - foreach ($chunk as $itemIndex => $item) { - $entry = [ - // Documents can't be mixed into an album; send them as photos/videos only. - 'type' => $item['type'] === 'document' ? 'document' : $item['type'], - 'media' => $item['url'], - ]; - - if ($chunkIndex === 0 && $itemIndex === 0 && $caption !== '') { - $entry['caption'] = $caption; - $entry['parse_mode'] = 'HTML'; - } - - $group[] = $entry; + if ($index === 0 && $caption !== '') { + $entry['caption'] = $caption; + $entry['parse_mode'] = 'HTML'; } - $response = $this->call('sendMediaGroup', [ - 'chat_id' => $chatId, - 'media' => json_encode($group), - ]); - - if ($chunkIndex === 0) { - $firstMessageId = (int) data_get($response->json(), 'result.0.message_id'); - } + $group[] = $entry; } - return $firstMessageId; + $response = $this->call('sendMediaGroup', [ + 'chat_id' => $chatId, + 'media' => json_encode($group), + ]); + + return (int) data_get($response->json(), 'result.0.message_id'); } /** diff --git a/resources/js/components/accounts/TelegramConnectDialog.vue b/resources/js/components/accounts/TelegramConnectDialog.vue index 2ff715b6..d295a372 100644 --- a/resources/js/components/accounts/TelegramConnectDialog.vue +++ b/resources/js/components/accounts/TelegramConnectDialog.vue @@ -23,17 +23,26 @@ import { const open = defineModel('open', { required: true }); type Phase = 'loading' | 'ready' | 'connected' | 'expired' | 'error'; +type ConnectStatus = 'unknown' | 'pending' | 'connected' | 'expired'; + +interface ConnectResponse { + code: string; + bot_username: string; + expires_at: string; +} + +const POLL_INTERVAL_MS = 3000; +const SUCCESS_CLOSE_DELAY_MS = 1200; const phase = ref('loading'); const code = ref(''); const botUsername = ref(''); const errorMessage = ref(''); -const httpConnect = useHttp< - Record, - { code: string; bot_username: string; expires_at: string } ->({}); -const httpStatus = useHttp, { status: string }>({}); +const httpConnect = useHttp, ConnectResponse>({}); +const httpStatus = useHttp, { status: ConnectStatus }>( + {}, +); let pollTimer: ReturnType | null = null; @@ -59,7 +68,7 @@ const poll = async () => { setTimeout(() => { open.value = false; router.reload(); - }, 1200); + }, SUCCESS_CLOSE_DELAY_MS); return; } @@ -72,7 +81,7 @@ const poll = async () => { // Transient polling failures are ignored; the next tick retries. } - pollTimer = setTimeout(poll, 3000); + pollTimer = setTimeout(poll, POLL_INTERVAL_MS); }; const start = async () => { @@ -176,13 +185,11 @@ onUnmounted(stopPolling); class="flex size-6 shrink-0 items-center justify-center rounded-full border-2 border-foreground text-xs font-semibold" >1 - + {{ + trans('accounts.telegram.step_admin', { + bot: `@${botUsername}`, + }) + }}
  • post->update([ + 'content' => 'A clip', + 'media' => [[ + 'id' => 'm1', + 'path' => 'media/clip.mp4', + 'url' => 'https://cdn.test/clip.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'clip.mp4', + ]], + ]); + + Http::fake([ + '*/botTESTTOKEN/sendVideo' => Http::response(telegramOk(['message_id' => 8]), 200), + ]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/sendVideo') + && str_contains($request['video'], 'clip.mp4') + && $request['caption'] === 'A clip'; + }); +}); + +test('telegram publisher sends a non-image, non-video file as a document', function () { + $this->post->update([ + 'content' => 'A file', + 'media' => [[ + 'id' => 'm1', + 'path' => 'media/report.pdf', + 'url' => 'https://cdn.test/report.pdf', + 'mime_type' => 'application/pdf', + 'original_filename' => 'report.pdf', + ]], + ]); + + Http::fake([ + '*/botTESTTOKEN/sendDocument' => Http::response(telegramOk(['message_id' => 9]), 200), + ]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/sendDocument') + && str_contains($request['document'], 'report.pdf'); + }); +}); + test('telegram publisher sends multiple media as an album', function () { $this->post->update([ 'content' => 'Album', diff --git a/tests/Feature/Social/TelegramConnectionTest.php b/tests/Feature/Social/TelegramConnectionTest.php index 489d6b76..e930537f 100644 --- a/tests/Feature/Social/TelegramConnectionTest.php +++ b/tests/Feature/Social/TelegramConnectionTest.php @@ -8,6 +8,7 @@ use App\Models\TelegramConnectRequest; use App\Models\User; use App\Models\Workspace; +use App\Services\Social\ConnectionVerifier; use Illuminate\Support\Facades\Http; beforeEach(function () { @@ -82,6 +83,25 @@ function telegramUpdate(string $code, array $chat = []): array expect($request->fresh()->social_account_id)->toBe($account->id); }); +it('links a private channel that has no username', function () { + TelegramConnectRequest::create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'code' => 'privatecode', + 'expires_at' => now()->addMinutes(15), + ]); + + $this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret') + ->postJson(route('telegram.webhook'), telegramUpdate('privatecode', ['username' => null])) + ->assertNoContent(); + + $account = SocialAccount::where('platform', Platform::Telegram)->first(); + + expect($account->username)->toBeNull(); + expect($account->display_name)->toBe('My Channel'); + expect(data_get($account->meta, 'username'))->toBeNull(); +}); + it('rejects the webhook without the secret token', function () { $this->postJson(route('telegram.webhook'), telegramUpdate('whatever')) ->assertForbidden(); @@ -128,6 +148,30 @@ function telegramUpdate(string $code, array $chat = []): array ->assertJson(['status' => 'connected']); }); +it('verifies a connected telegram account via getChat', function () { + config(['trypost.platforms.telegram.bot_token' => 'TESTTOKEN']); + + $account = SocialAccount::factory()->telegram()->create(['workspace_id' => $this->workspace->id]); + + Http::fake([ + '*/botTESTTOKEN/getChat*' => Http::response(['ok' => true, 'result' => ['id' => -1001234567890]], 200), + ]); + + expect(app(ConnectionVerifier::class)->verify($account))->toBeTrue(); +}); + +it('reports a telegram account as invalid when getChat fails', function () { + config(['trypost.platforms.telegram.bot_token' => 'TESTTOKEN']); + + $account = SocialAccount::factory()->telegram()->create(['workspace_id' => $this->workspace->id]); + + Http::fake([ + '*/botTESTTOKEN/getChat*' => Http::response(['ok' => false, 'description' => 'chat not found'], 400), + ]); + + expect(app(ConnectionVerifier::class)->verify($account))->toBeFalse(); +}); + it('registers the webhook via the artisan command', function () { Http::fake([ '*/botTESTTOKEN/setWebhook' => Http::response(['ok' => true, 'result' => true], 200), diff --git a/tests/Unit/Exceptions/Social/TelegramPublishExceptionTest.php b/tests/Unit/Exceptions/Social/TelegramPublishExceptionTest.php new file mode 100644 index 00000000..1e577d7d --- /dev/null +++ b/tests/Unit/Exceptions/Social/TelegramPublishExceptionTest.php @@ -0,0 +1,63 @@ + Http::response($body, $status)])->post('https://api.telegram.org/botX/sendMessage'); +} + +test('HTTP 403 maps to Permission category', function () { + $exception = TelegramPublishException::fromApiResponse( + telegramErrorResponse(['ok' => false, 'description' => 'Forbidden'], 403), + ); + + expect($exception->category)->toBe(ErrorCategory::Permission) + ->and($exception->platformErrorCode)->toBe('403'); +}); + +test('HTTP 401 maps to Permission category', function () { + $exception = TelegramPublishException::fromApiResponse( + telegramErrorResponse(['ok' => false, 'description' => 'Unauthorized'], 401), + ); + + expect($exception->category)->toBe(ErrorCategory::Permission) + ->and($exception->platformErrorCode)->toBe('401'); +}); + +test('HTTP 429 maps to RateLimit category', function () { + $exception = TelegramPublishException::fromApiResponse( + telegramErrorResponse(['ok' => false, 'description' => 'Too Many Requests'], 429), + ); + + expect($exception->category)->toBe(ErrorCategory::RateLimit); +}); + +test('HTTP 500 maps to ServerError category', function () { + $exception = TelegramPublishException::fromApiResponse( + telegramErrorResponse(['ok' => false, 'description' => 'Internal'], 500), + ); + + expect($exception->category)->toBe(ErrorCategory::ServerError); +}); + +test('other errors map to Unknown category with the api description', function () { + $exception = TelegramPublishException::fromApiResponse( + telegramErrorResponse(['ok' => false, 'description' => 'Bad Request: chat not found'], 400), + ); + + expect($exception->category)->toBe(ErrorCategory::Unknown) + ->and($exception->userMessage)->toBe('Bad Request: chat not found'); +}); + +test('platform returns telegram', function () { + $exception = TelegramPublishException::fromApiResponse( + telegramErrorResponse(['ok' => false, 'description' => 'Error'], 400), + ); + + expect($exception->platform())->toBe('telegram'); +});