Address PR review: connect-status enum, publisher cleanup, fill test gaps
This commit is contained in:
parent
deb1c6fa69
commit
2585d89cb4
9 changed files with 224 additions and 49 deletions
28
app/Enums/SocialAccount/TelegramConnectStatus.php
Normal file
28
app/Enums/SocialAccount/TelegramConnectStatus.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
Loading…
Reference in a new issue