Files
chatroom/tests/Feature/ChatControllerTest.php

626 lines
22 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 文件功能:聊天室控制器功能测试
*
* 覆盖进房、发言、图片消息、权限限制与广播范围等关键聊天流程。
*/
namespace Tests\Feature;
use App\Events\MessageSent;
use App\Models\Room;
use App\Models\User;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
/**
* 类功能:验证聊天室核心控制器的关键行为。
*/
class ChatControllerTest extends TestCase
{
use RefreshDatabase;
/**
* 每个测试前清空 Redis避免跨用例污染在线状态与消息缓存。
*/
protected function setUp(): void
{
parent::setUp();
Redis::flushall();
}
/**
* 测试用户可以正常进入聊天室页面。
*/
public function test_can_view_room(): void
{
$room = Room::create(['room_name' => 'testroom']);
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
$response->assertStatus(200);
$response->assertViewIs('chat.frame');
// Assert user was added to room in redis
$this->assertEquals(1, Redis::hexists("room:{$room->id}:users", $user->username));
}
/**
* 测试关闭房间会拒绝普通用户直接进入。
*/
public function test_cannot_view_closed_room_without_bypass_permission(): void
{
$room = Room::create([
'room_name' => 'closed',
'door_open' => false,
]);
$user = User::factory()->create([
'user_level' => 1,
]);
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
$response->assertForbidden();
$this->assertSame(0, Redis::hexists("room:{$room->id}:users", $user->username));
}
/**
* 测试等级不足的用户不能进入受限房间。
*/
public function test_cannot_view_room_when_level_is_below_permit_level(): void
{
$room = Room::create([
'room_name' => 'viprm',
'permit_level' => 10,
'door_open' => true,
]);
$user = User::factory()->create([
'user_level' => 5,
]);
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
$response->assertForbidden();
$this->assertSame(0, Redis::hexists("room:{$room->id}:users", $user->username));
}
/**
* 测试只有已成功进入房间的用户才可通过 Presence 频道鉴权。
*/
public function test_room_presence_channel_requires_room_access_and_join_state(): void
{
$room = Room::create([
'room_name' => 'guard',
'door_open' => true,
]);
$user = User::factory()->create();
$channelCallback = Broadcast::driver()->getChannels()->get('room.{roomId}');
$this->assertIsCallable($channelCallback);
$this->assertFalse($channelCallback($user, (string) $room->id));
$this->actingAs($user)->get(route('chat.room', $room->id));
$authorizedPayload = $channelCallback($user, (string) $room->id);
$this->assertIsArray($authorizedPayload);
$this->assertSame($user->id, $authorizedPayload['id'] ?? null);
Redis::del("room:{$room->id}:users");
$this->assertFalse($channelCallback($user, (string) $room->id));
}
/**
* 测试主干默认聊天室页面不会渲染虚拟形象挂载点和配置。
*/
public function test_room_view_does_not_render_avatar_widget_or_config_by_default(): void
{
$room = Room::create(['room_name' => 'avguard']);
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
$response->assertOk();
$response->assertDontSee('chat-avatar-widget');
$response->assertDontSee('chatAvatarWidget');
}
/**
* 测试聊天室输入区会渲染系统播报屏蔽按钮与对应选项。
*/
public function test_room_view_renders_block_system_sender_controls(): void
{
$room = Room::create(['room_name' => 'blockmenu']);
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
$response->assertOk();
$response->assertSee('🔕 屏蔽', false);
$response->assertSee('🔇 禁音', false);
$response->assertSee('钓鱼播报');
$response->assertSee('星海小博士');
$response->assertSee('百家乐');
$response->assertSee('跑马');
$response->assertSee('神秘箱子');
$response->assertSee('chat_blocked_system_senders');
$response->assertSee('toggleBlockedSystemSender');
}
/**
* 测试用户可以发送普通文本消息。
*/
public function test_can_send_message(): void
{
$room = Room::create(['room_name' => 'test_send']);
$user = User::factory()->create();
// 进房
$this->actingAs($user)->get(route('chat.room', $room->id));
$response = $this->actingAs($user)->postJson(route('chat.send', $room->id), [
'to_user' => '大家',
'content' => '测试消息',
'is_secret' => false,
'font_color' => '#000000',
'action' => '微笑',
]);
$response->assertStatus(200);
$response->assertJson(['status' => 'success']);
// 查看 Redis 里的消息记录
$messages = Redis::lrange("room:{$room->id}:messages", 0, -1);
$this->assertNotEmpty($messages);
$found = false;
foreach ($messages as $msgJson) {
$msg = json_decode($msgJson, true);
if ($msg['from_user'] === $user->username && $msg['content'] === '测试消息') {
$found = true;
break;
}
}
$this->assertTrue($found, 'Message not found in Redis');
}
/**
* 测试发送接口会拦截不在白名单内的危险动作值。
*/
public function test_send_message_rejects_invalid_action_value(): void
{
$room = Room::create(['room_name' => 'badact']);
$user = User::factory()->create();
$this->actingAs($user)->get(route('chat.room', $room->id));
$response = $this->actingAs($user)->postJson(route('chat.send', $room->id), [
'to_user' => '大家',
'content' => '危险动作测试',
'is_secret' => false,
'font_color' => '#000000',
'action' => '\"></span><img src=x onerror=alert(1)>',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors('action');
}
/**
* 测试定向消息仅广播到发送方与接收方私有频道。
*/
public function test_targeted_message_event_uses_private_user_channels(): void
{
$sender = User::factory()->create(['username' => 'sender-user']);
$receiver = User::factory()->create(['username' => 'receiver-user']);
$event = new MessageSent(1, [
'room_id' => 1,
'from_user' => $sender->username,
'to_user' => $receiver->username,
'content' => '只给你看',
'is_secret' => true,
'font_color' => '#000000',
'action' => '',
'sent_at' => now()->toDateTimeString(),
]);
$channels = $event->broadcastOn();
$this->assertCount(2, $channels);
$this->assertContainsOnlyInstancesOf(PrivateChannel::class, $channels);
$this->assertSame([
'private-user.'.$sender->id,
'private-user.'.$receiver->id,
], array_map(fn (PrivateChannel $channel) => $channel->name, $channels));
}
/**
* 测试公共消息仍广播到房间 Presence 频道。
*/
public function test_public_message_event_still_uses_room_presence_channel(): void
{
$event = new MessageSent(3, [
'room_id' => 3,
'from_user' => 'tester',
'to_user' => '大家',
'content' => '公开消息',
'is_secret' => false,
'font_color' => '#000000',
'action' => '',
'sent_at' => now()->toDateTimeString(),
]);
$channels = $event->broadcastOn();
$this->assertCount(1, $channels);
$this->assertInstanceOf(PresenceChannel::class, $channels[0]);
$this->assertSame('presence-room.3', $channels[0]->name);
}
/**
* 测试文本内容为字符串 0 时仍可正常发送。
*/
public function test_can_send_zero_message_content(): void
{
$room = Room::create(['room_name' => 'send0']);
$user = User::factory()->create();
$this->actingAs($user)->get(route('chat.room', $room->id));
$response = $this->actingAs($user)->postJson(route('chat.send', $room->id), [
'to_user' => '大家',
'content' => '0',
'is_secret' => false,
'font_color' => '#000000',
'action' => '',
]);
$response->assertOk();
$response->assertJson(['status' => 'success']);
$messages = Redis::lrange("room:{$room->id}:messages", 0, -1);
$found = false;
foreach ($messages as $msgJson) {
$msg = json_decode($msgJson, true);
if ($msg['from_user'] === $user->username && $msg['content'] === '0') {
$found = true;
break;
}
}
$this->assertTrue($found, 'Zero message not found in Redis');
}
/**
* 测试用户可以发送带缩略图的图片消息。
*/
public function test_can_send_image_message(): void
{
Storage::fake('public');
$room = Room::create(['room_name' => 'imgsend']);
$user = User::factory()->create();
$this->actingAs($user)->get(route('chat.room', $room->id));
$response = $this->actingAs($user)->post(route('chat.send', $room->id), [
'to_user' => '大家',
'content' => '图片说明',
'font_color' => '#000000',
'action' => '',
'image' => UploadedFile::fake()->image('chat-picture.png', 1280, 960),
], [
'Accept' => 'application/json',
]);
$response->assertOk();
$response->assertJson(['status' => 'success']);
$messages = Redis::lrange("room:{$room->id}:messages", 0, -1);
$payload = collect($messages)
->map(fn (string $item) => json_decode($item, true))
->first(fn (array $item) => ($item['from_user'] ?? null) === $user->username && ($item['message_type'] ?? null) === 'image');
$this->assertNotNull($payload);
$this->assertSame('image', $payload['message_type'] ?? null);
$this->assertNotEmpty($payload['image_path'] ?? null);
$this->assertNotEmpty($payload['image_thumb_path'] ?? null);
$this->assertNotEmpty($payload['image_url'] ?? null);
$this->assertNotEmpty($payload['image_thumb_url'] ?? null);
Storage::disk('public')->assertExists($payload['image_path']);
Storage::disk('public')->assertExists($payload['image_thumb_path']);
}
/**
* 测试聊天室发送接口在 419 场景下会返回稳定的 JSON 提示。
*/
public function test_chat_send_http_419_exception_renders_json_response(): void
{
$request = Request::create('/room/1/send', 'POST', server: [
'HTTP_ACCEPT' => 'application/json',
]);
$response = $this->app->make(\Illuminate\Contracts\Debug\ExceptionHandler::class)
->render($request, new HttpException(419, 'Page Expired'));
\Illuminate\Testing\TestResponse::fromBaseResponse($response)->assertStatus(419)->assertJson([
'status' => 'error',
'message' => '页面已过期,请刷新后重试。',
]);
}
/**
* 测试超过保留期的聊天图片会被命令清理并改成过期占位消息。
*/
public function test_purge_command_cleans_expired_chat_images(): void
{
Storage::fake('public');
Storage::disk('public')->put('chat-images/2026-04-08/sample_original.png', 'original');
Storage::disk('public')->put('chat-images/2026-04-08/sample_thumb.png', 'thumb');
$message = \App\Models\Message::create([
'room_id' => 1,
'from_user' => 'tester',
'to_user' => '大家',
'content' => '历史图片',
'is_secret' => false,
'font_color' => '#000000',
'action' => '',
'message_type' => 'image',
'image_path' => 'chat-images/2026-04-08/sample_original.png',
'image_thumb_path' => 'chat-images/2026-04-08/sample_thumb.png',
'image_original_name' => 'sample.png',
'sent_at' => now()->subDays(4),
]);
$this->artisan('messages:purge', [
'--days' => 30,
'--image-days' => 3,
])->assertExitCode(0);
$message->refresh();
$this->assertSame('expired_image', $message->message_type);
$this->assertNull($message->image_path);
$this->assertNull($message->image_thumb_path);
$this->assertNull($message->image_original_name);
$this->assertStringContainsString('图片已过期', $message->content);
Storage::disk('public')->assertMissing('chat-images/2026-04-08/sample_original.png');
Storage::disk('public')->assertMissing('chat-images/2026-04-08/sample_thumb.png');
}
/**
* 测试心跳接口可以正常返回成功响应。
*/
public function test_can_trigger_heartbeat()
{
$room = Room::create(['room_name' => 'test_hb']);
$user = User::factory()->create(['exp_num' => 0]);
$response = $this->actingAs($user)->postJson(route('chat.heartbeat', $room->id));
$response->assertStatus(200);
$response->assertJsonFragment(['status' => 'success']);
$user->refresh();
$this->assertGreaterThanOrEqual(0, $user->exp_num); // Might be 1 depending on sysparam
}
/**
* 测试显式退房会清理 Redis 在线状态。
*/
public function test_can_leave_room()
{
$room = Room::create(['room_name' => 'test_leave']);
$user = User::factory()->create();
// 进房
$this->actingAs($user)->get(route('chat.room', $room->id));
$this->assertEquals(1, Redis::hexists("room:{$room->id}:users", $user->username));
// 显式退房
$response = $this->actingAs($user)->postJson(route('chat.leave', $room->id).'?explicit=1');
$response->assertStatus(200);
// 缓存中被移除
$this->assertEquals(0, Redis::hexists("room:{$room->id}:users", $user->username));
}
/**
* 测试签名退房链接同样可以正常清理在线状态。
*/
public function test_can_leave_room_through_signed_expired_route(): void
{
$room = Room::create(['room_name' => 'leave2']);
$user = User::factory()->create();
$this->actingAs($user)->get(route('chat.room', $room->id));
$this->assertEquals(1, Redis::hexists("room:{$room->id}:users", $user->username));
$url = URL::temporarySignedRoute('chat.leave.expired', now()->addMinutes(5), [
'id' => $room->id,
'user' => $user->id,
]);
$response = $this->getJson($url);
$response->assertStatus(200);
$this->assertEquals(0, Redis::hexists("room:{$room->id}:users", $user->username));
}
/**
* 测试双击名片赠金币会写入私聊消息,且只有赠送双方能看到历史记录。
*/
public function test_gift_gold_creates_secret_message_visible_only_to_participants(): void
{
$room = Room::create(['room_name' => 'gift-gold']);
$sender = User::factory()->create(['jjb' => 500]);
$receiver = User::factory()->create(['jjb' => 100]);
$outsider = User::factory()->create();
$response = $this->actingAs($sender)->postJson(route('gift.gold'), [
'to_user' => $receiver->username,
'room_id' => $room->id,
'amount' => 88,
]);
$response->assertOk();
$response->assertJsonFragment([
'status' => 'success',
'message' => "赠送成功!已向 {$receiver->username} 赠送 88 金币。",
]);
// 余额应同步完成转移,确保消息不是“通知成功但金额未变”。
$this->assertSame(412, $sender->fresh()->jjb);
$this->assertSame(188, $receiver->fresh()->jjb);
$messages = Redis::lrange("room:{$room->id}:messages", 0, -1);
$giftMessage = collect($messages)
->map(fn (string $item) => json_decode($item, true))
->first(fn (array $item) => ($item['from_user'] ?? null) === $sender->username
&& ($item['to_user'] ?? null) === $receiver->username
&& ($item['content'] ?? null) === '赠送了你 88 金币!💝');
$this->assertNotNull($giftMessage);
$this->assertTrue((bool) ($giftMessage['is_secret'] ?? false));
$this->assertSame('💰 赠金币到账', $giftMessage['toast_notification']['title'] ?? null);
$this->assertSame('💰', $giftMessage['toast_notification']['icon'] ?? null);
$this->assertSame('#f59e0b', $giftMessage['toast_notification']['color'] ?? null);
// 赠送方查看房间历史时,应保留这条私聊通知。
$senderHistory = $this->actingAs($sender)
->get(route('chat.room', $room->id))
->viewData('historyMessages');
$this->assertTrue(collect($senderHistory)->contains(
fn (array $item) => ($item['from_user'] ?? null) === $sender->username
&& ($item['to_user'] ?? null) === $receiver->username
&& ($item['content'] ?? null) === '赠送了你 88 金币!💝'
));
// 接收方查看房间历史时,也应看到这条私聊通知。
$receiverHistory = $this->actingAs($receiver)
->get(route('chat.room', $room->id))
->viewData('historyMessages');
$this->assertTrue(collect($receiverHistory)->contains(
fn (array $item) => ($item['from_user'] ?? null) === $sender->username
&& ($item['to_user'] ?? null) === $receiver->username
&& ($item['content'] ?? null) === '赠送了你 88 金币!💝'
));
// 旁观者不应在历史消息里看到别人的赠金币私聊。
$outsiderHistory = $this->actingAs($outsider)
->get(route('chat.room', $room->id))
->viewData('historyMessages');
$this->assertFalse(collect($outsiderHistory)->contains(
fn (array $item) => ($item['from_user'] ?? null) === $sender->username
&& ($item['to_user'] ?? null) === $receiver->username
&& ($item['content'] ?? null) === '赠送了你 88 金币!💝'
));
}
/**
* 测试会员用户首次进房时会把专属欢迎主题写入历史消息。
*/
public function test_vip_user_join_message_uses_presence_theme_payload(): void
{
$room = Room::create(['room_name' => 'viproom']);
$vipLevel = \App\Models\VipLevel::factory()->create([
'join_effect' => 'lightning',
'join_banner_style' => 'storm',
'allow_custom_messages' => true,
]);
$user = User::factory()->create([
'vip_level_id' => $vipLevel->id,
'hy_time' => now()->addDays(30),
'custom_join_message' => '{username} 带着风暴王座闪耀降临',
'has_received_new_gift' => true,
]);
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
$response->assertStatus(200);
$history = $response->viewData('historyMessages');
$presenceMessage = collect($history)->first(fn (array $message) => ($message['action'] ?? '') === 'vip_presence');
$this->assertNotNull($presenceMessage);
$this->assertSame('join', $presenceMessage['presence_type']);
$this->assertSame('lightning', $presenceMessage['presence_effect']);
$this->assertSame('storm', $presenceMessage['presence_banner_style']);
$this->assertStringContainsString($user->username, $presenceMessage['presence_text']);
}
/**
* 测试可以获取所有房间的在线人数状态。
*/
public function test_can_get_rooms_online_status()
{
$user = User::factory()->create();
$room1 = Room::create(['room_name' => 'room1']);
$room2 = Room::create(['room_name' => 'room2']);
$this->actingAs($user)->get(route('chat.room', $room1->id));
$response = $this->actingAs($user)->getJson(route('chat.rooms-online-status'));
$response->assertStatus(200);
// Assert room1 has 1 online, room2 has 0
$response->assertJsonFragment([
'id' => $room1->id,
'online' => 1,
]);
$response->assertJsonFragment([
'id' => $room2->id,
'online' => 0,
]);
}
/**
* 测试管理员可以设置房间公告。
*/
public function test_can_set_announcement()
{
$user = User::factory()->create(['user_level' => 100]); // superadmin
$room = Room::create(['room_name' => 'test_ann', 'room_owner' => 'someone']);
$response = $this->actingAs($user)->postJson(route('chat.announcement', $room->id), [
'announcement' => 'This is a new test announcement',
]);
$response->assertStatus(200);
$room->refresh();
$this->assertStringContainsString('This is a new test announcement', $room->announcement);
}
/**
* 测试无权限用户不能设置房间公告。
*/
public function test_cannot_set_announcement_without_permission()
{
$user = User::factory()->create(['user_level' => 0]);
$room = Room::create(['room_name' => 'test_ann2', 'room_owner' => 'someone']);
$response = $this->actingAs($user)->postJson(route('chat.announcement', $room->id), [
'announcement' => 'This is a new test announcement',
]);
$response->assertStatus(403);
}
}