Address PR review: connect-status enum, publisher cleanup, fill test gaps

This commit is contained in:
Paulo Castellano 2026-06-13 22:38:23 -03:00
parent deb1c6fa69
commit 2585d89cb4
9 changed files with 224 additions and 49 deletions

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Enums\SocialAccount;
use App\Models\TelegramConnectRequest;
enum TelegramConnectStatus: string
{
case Unknown = 'unknown';
case Pending = 'pending';
case Connected = 'connected';
case Expired = 'expired';
/**
* Derive the connection status the frontend polls for from a connect request.
*/
public static function for(?TelegramConnectRequest $request): self
{
return match (true) {
$request === null => self::Unknown,
$request->social_account_id !== null => self::Connected,
$request->isExpired() => self::Expired,
default => self::Pending,
};
}
}

View file

@ -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,
);

View file

@ -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,
]);
}
}

View file

@ -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,

View file

@ -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');
}
/**

View file

@ -23,17 +23,26 @@ import {
const open = defineModel<boolean>('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<Phase>('loading');
const code = ref('');
const botUsername = ref('');
const errorMessage = ref('');
const httpConnect = useHttp<
Record<string, never>,
{ code: string; bot_username: string; expires_at: string }
>({});
const httpStatus = useHttp<Record<string, never>, { status: string }>({});
const httpConnect = useHttp<Record<string, never>, ConnectResponse>({});
const httpStatus = useHttp<Record<string, never>, { status: ConnectStatus }>(
{},
);
let pollTimer: ReturnType<typeof setTimeout> | 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</span
>
<span
v-html="
trans('accounts.telegram.step_admin', {
bot: `@${botUsername}`,
})
"
/>
<span>{{
trans('accounts.telegram.step_admin', {
bot: `@${botUsername}`,
})
}}</span>
</li>
<li class="flex gap-3">
<span

View file

@ -88,6 +88,55 @@ function telegramOk(array $result): array
});
});
test('telegram publisher sends a single video', function () {
$this->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',

View file

@ -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),

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\TelegramPublishException;
use Illuminate\Support\Facades\Http;
function telegramErrorResponse(array $body, int $status)
{
return Http::fake(['*' => 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');
});