dpl/tests/Feature/TelegramTickets/TicketBridgeTest.php

148 lines
12 KiB
PHP

<?php
namespace Tests\Feature\TelegramTickets;
use App\Jobs\SendEmailJob;
use App\Jobs\SendTelegramTicketJob;
use App\Models\Ticket;
use App\Models\TicketMessage;
use App\Models\TelegramTicketMessage;
use App\Models\TelegramTicketUpdate;
use App\Models\User;
use App\Services\TelegramTickets\TicketBridge;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class TicketBridgeTest extends TestCase {
private User $admin;private User $customer;private Ticket $ticket;private TicketBridge $bridge;
protected function setUp():void {
parent::setUp();config(['database.default'=>'sqlite','database.connections.sqlite.database'=>':memory:','cache.default'=>'array','cache.stores.redis'=>['driver'=>'array'],'queue.default'=>'sync']);
DB::purge('sqlite');Cache::forgetDriver(['array','redis']);$this->app->forgetInstance(\App\Support\Setting::class);$this->app->instance('hook.actions',[]);
Schema::create('v2_settings',fn(Blueprint $t)=>[$t->id(),$t->string('name'),$t->text('value')->nullable(),$t->timestamps()]);
Schema::create('v2_user',function(Blueprint $t){$t->id();$t->string('email');$t->boolean('is_admin')->default(false);$t->boolean('banned')->default(false);$t->boolean('remind_expire')->default(true);$t->integer('plan_id')->nullable();$t->integer('expired_at')->nullable();$t->bigInteger('telegram_id')->nullable();$t->integer('created_at')->nullable();$t->integer('updated_at')->nullable();});
Schema::create('v2_ticket',function(Blueprint $t){$t->id();$t->integer('user_id');$t->string('subject');$t->integer('status')->default(0);$t->integer('reply_status')->default(0);$t->integer('last_reply_user_id')->nullable();$t->integer('created_at')->nullable();$t->integer('updated_at')->nullable();});
Schema::create('v2_ticket_message',function(Blueprint $t){$t->id();$t->integer('ticket_id');$t->integer('user_id');$t->text('message');$t->integer('created_at')->nullable();$t->integer('updated_at')->nullable();});
(require base_path('database/migrations/2026_10_11_140000_create_telegram_ticket_tables.php'))->up();
$this->admin=User::withoutEvents(fn()=>User::create(['email'=>'admin@example.test','is_admin'=>1,'telegram_id'=>123]));
$this->customer=User::withoutEvents(fn()=>User::create(['email'=>'customer@example.test']));
$this->ticket=Ticket::create(['user_id'=>$this->customer->id,'subject'=>'Test']);
$source=TicketMessage::create(['user_id'=>$this->customer->id,'ticket_id'=>$this->ticket->id,'message'=>'Question']);
admin_setting(['telegram_bot_enable'=>true,'telegram_bot_token'=>'synthetic-token','telegram_ticket'=>['notify_enabled'=>true,'reply_enabled'=>true,'receiver_user_ids'=>[]]]);
$this->bridge=app(TicketBridge::class);
TelegramTicketMessage::create(['bot_key'=>$this->bridge->botKey(),'ticket_id'=>$this->ticket->id,'ticket_message_id'=>$source->id,'receiver_user_id'=>$this->admin->id,'chat_id'=>123,'telegram_message_id'=>456,'status'=>'sent']);
Bus::fake([SendEmailJob::class,SendTelegramTicketJob::class]);Http::preventStrayRequests();
}
private function message(array $extra=[]):array{return array_replace_recursive(['chat'=>['id'=>123,'type'=>'private'],'from'=>['id'=>123,'is_bot'=>false],'text'=>'Answer','reply_to_message'=>['message_id'=>456]],$extra);}
public function test_reply_saves_once_and_queues_one_email():void {
$this->assertStringContainsString('回复已保存',$this->bridge->reply($this->message(),1));
$this->assertSame(2,TicketMessage::count());$this->assertSame(1,(int)$this->ticket->fresh()->reply_status);
$this->assertSame($this->admin->id,TicketMessage::orderByDesc('id')->first()->user_id);
$this->assertStringContainsString('已经处理',$this->bridge->reply($this->message(),1));
$this->assertSame(2,TicketMessage::count());Bus::assertDispatched(SendEmailJob::class,1);Http::assertNothingSent();
}
public function test_unauthorized_group_closed_and_unknown_messages_do_not_save():void {
$this->assertStringContainsString('只接受',$this->bridge->reply($this->message(['chat'=>['type'=>'group']]),1));
$this->admin->is_admin=0;$this->admin->save();$this->assertStringContainsString('没有',$this->bridge->reply($this->message(),2));
$this->admin->is_admin=1;$this->admin->save();$this->ticket->status=1;$this->ticket->save();
$this->assertStringContainsString('已关闭',$this->bridge->reply($this->message(),3));
$this->assertNull($this->bridge->reply($this->message(['reply_to_message'=>['message_id'=>999]]),4));
$this->assertSame(1,TicketMessage::count());Bus::assertNotDispatched(SendEmailJob::class);
}
public function test_missing_email_and_cooldown_do_not_prevent_saved_replies():void {
$this->bridge->reply($this->message(),1);$this->bridge->reply($this->message(['text'=>'Second answer']),2);
$this->assertSame(3,TicketMessage::count());Bus::assertDispatched(SendEmailJob::class,1);
$this->customer->email='unbound';$this->customer->save();Cache::flush();
$this->bridge->reply($this->message(['text'=>'No email']),3);$this->assertSame(4,TicketMessage::count());Bus::assertDispatched(SendEmailJob::class,1);
}
public function test_disabled_removed_receiver_and_rotated_bot_cannot_reply():void {
admin_setting(['telegram_ticket'=>['reply_enabled'=>false]]);$this->assertStringContainsString('尚未启用',$this->bridge->reply($this->message(),1));
admin_setting(['telegram_ticket'=>['reply_enabled'=>true,'receiver_user_ids'=>[999]]]);$this->assertStringContainsString('没有',$this->bridge->reply($this->message(),2));
admin_setting(['telegram_bot_token'=>'rotated-token']);$this->assertNull($this->bridge->reply($this->message(),3));$this->assertSame(1,TicketMessage::count());
}
public function test_outbound_send_creates_mapping_and_does_not_resend():void {
$r=TelegramTicketMessage::first();$r->status='pending';$r->telegram_message_id=null;$r->save();
Http::fake(['api.telegram.org/*'=>Http::response(['ok'=>true,'result'=>['message_id'=>789]],200)]);
$job=new SendTelegramTicketJob($r->id);$job->handle($this->bridge);$job->handle($this->bridge);
$this->assertSame('sent',$r->fresh()->status);$this->assertSame(789,$r->fresh()->telegram_message_id);Http::assertSentCount(1);
}
public function test_timeout_is_unknown_and_never_automatically_resubmitted():void {
$r=TelegramTicketMessage::first();$r->status='pending';$r->telegram_message_id=null;$r->save();
Http::fake(['api.telegram.org/*'=>Http::failedConnection()]);$j=new SendTelegramTicketJob($r->id);$j->handle($this->bridge);$j->handle($this->bridge);$this->assertSame('unknown',$r->fresh()->status);
}
public function test_binding_code_is_private_one_time_and_does_not_replace_existing_binding():void {
$code=$this->bridge->bindingCode($this->admin);$this->assertStringContainsString('绑定成功',$this->bridge->bind($this->message(),$code));
$this->assertStringContainsString('无效',$this->bridge->bind($this->message(),$code));
$code=$this->bridge->bindingCode($this->admin);$m=$this->message(['chat'=>['id'=>321],'from'=>['id'=>321]]);
$this->assertStringContainsString('已绑定其他',$this->bridge->bind($m,$code));$this->assertSame(123,(int)$this->admin->fresh()->telegram_id);
}
public function test_enqueuing_same_source_only_creates_one_record_per_admin():void {
$this->bridge->enqueue($this->ticket);$this->bridge->enqueue($this->ticket);$this->assertSame(1,TelegramTicketMessage::count());
}
public function test_rate_limit_waits_until_retry_after_before_sending_again():void {
$r=TelegramTicketMessage::first();$r->update(['status'=>'pending','telegram_message_id'=>null]);
Http::fake(['api.telegram.org/*'=>Http::response(['ok'=>false,'parameters'=>['retry_after'=>3600]],429)]);
$job=new SendTelegramTicketJob($r->id);$job->handle($this->bridge);$job->handle($this->bridge);
$this->assertSame('pending',$r->fresh()->status);$this->assertTrue($r->fresh()->available_at->isFuture());Http::assertSentCount(1);
}
public function test_spoofed_sender_and_other_users_binding_are_rejected():void {
$this->assertStringContainsString('只接受',$this->bridge->reply($this->message(['from'=>['id'=>999]]),1));
$this->customer->telegram_id=999;$this->customer->save();$code=$this->bridge->bindingCode($this->admin);
$this->assertStringContainsString('已绑定其他网站',$this->bridge->bind($this->message(['chat'=>['id'=>999],'from'=>['id'=>999]]),$code));
$this->assertSame(1,TicketMessage::count());$this->assertSame(123,(int)$this->admin->fresh()->telegram_id);
}
public function test_real_webhook_routes_reply_with_original_text_and_message_id():void {
$this->app->instance('hook.filters',[]);
require_once base_path('plugins-core/Telegram/Plugin.php');
Http::fake(['api.telegram.org/*'=>Http::response(['ok'=>true,'result'=>['message_id'=>987]],200)]);
$plugin=new \Plugin\Telegram\Plugin('telegram');$plugin->boot();
admin_setting(['telegram_webhook_secret'=>'testsecret']);
$message=$this->message(['message_id'=>567,'reply_to_message'=>['text'=>'Ticket notification']]);
$request=\Illuminate\Http\Request::create('/','POST',[],[],[],['CONTENT_TYPE'=>'application/json'],json_encode(['update_id'=>55,'message'=>$message]));
$request->headers->set('X-Telegram-Bot-Api-Secret-Token','testsecret');
app(\App\Http\Controllers\V1\Guest\TelegramController::class)->webhook($request);
$this->assertSame(2,TicketMessage::count());
Bus::assertDispatched(SendEmailJob::class,1);
$diagnostic=Cache::get('tg_ticket_inbound_'.$this->bridge->botKey().'_'.$this->admin->id);
$this->assertTrue($diagnostic['is_reply']);$this->assertTrue($diagnostic['matched_ticket']);
$this->assertArrayNotHasKey('text',$diagnostic);
}
public function test_webhook_preserves_original_text_requirement_for_ticket_replies():void {
$this->app->instance('hook.filters',[]);
require_once base_path('plugins-core/Telegram/Plugin.php');
Http::fake(['api.telegram.org/*'=>Http::response(['ok'=>true,'result'=>['message_id'=>987]],200)]);
$plugin=new \Plugin\Telegram\Plugin('telegram');$plugin->boot();
admin_setting(['telegram_webhook_secret'=>'testsecret']);
$request=\Illuminate\Http\Request::create('/','POST',[],[],[],['CONTENT_TYPE'=>'application/json'],json_encode(['update_id'=>56,'message'=>$this->message(['message_id'=>568])]));
$request->headers->set('X-Telegram-Bot-Api-Secret-Token','testsecret');
app(\App\Http\Controllers\V1\Guest\TelegramController::class)->webhook($request);
$this->assertSame(1,TicketMessage::count());Bus::assertNotDispatched(SendEmailJob::class);
}
public function test_routing_diagnostics_distinguish_unmatched_reply_and_ignore_customers():void {
$this->bridge->observeIncoming($this->message(['reply_to_message'=>['message_id'=>999]]),9);
$key='tg_ticket_inbound_'.$this->bridge->botKey().'_'.$this->admin->id;
$this->assertFalse(Cache::get($key)['matched_ticket']);
$message=$this->message();unset($message['reply_to_message']);$this->bridge->observeIncoming($message,10);
$this->assertFalse(Cache::get($key)['is_reply']);
$this->bridge->observeIncoming($this->message(['from'=>['id'=>999]]),11);
$this->assertSame(10,Cache::get($key)['update_id']);
}
public function test_webhook_checks_secret_header_and_refuses_another_bot_webhook():void {
Http::fake(['api.telegram.org/*/getMe'=>Http::response(['ok'=>true,'result'=>['username'=>'testbot']],200),
'api.telegram.org/*/getWebhookInfo'=>Http::response(['ok'=>true,'result'=>['url'=>'https://another.example/webhook']],200)]);
admin_setting(['app_url'=>'https://example.test']);
$controller=app(\App\Http\Controllers\V2\Admin\ConfigController::class);
$response=$controller->setTelegramWebhook(\Illuminate\Http\Request::create('/','POST'));
$this->assertSame(422,$response->getStatusCode());Http::assertSentCount(2);
admin_setting(['telegram_webhook_secret'=>'testsecret']);
$request=\Illuminate\Http\Request::create('/','POST',[],[],[],['CONTENT_TYPE'=>'application/json'],'{}');
try{app(\App\Http\Controllers\V1\Guest\TelegramController::class)->webhook($request);$this->fail('Missing secret must be rejected');}catch(\App\Exceptions\ApiException $e){$this->assertTrue(true);}
$request->headers->set('X-Telegram-Bot-Api-Secret-Token','testsecret');
app(\App\Http\Controllers\V1\Guest\TelegramController::class)->webhook($request);
$this->assertSame(1,TicketMessage::count());
}
}