Files
chatroom/tests/Feature/Feature/AdminCommandControllerTest.php
T

777 lines
27 KiB
PHP

<?php
/**
* 文件功能:管理员聊天命令功能测试
* 负责验证聊天室管理员命令接口对新特效类型的支持情况。
*/
namespace Tests\Feature\Feature;
use App\Events\BrowserRefreshRequested;
use App\Jobs\SaveMessageJob;
use App\Models\Department;
use App\Models\Position;
use App\Models\Room;
use App\Models\User;
use App\Models\UserPosition;
use App\Support\PositionPermissionRegistry;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Redis;
use Tests\TestCase;
/**
* 管理员聊天命令功能测试
* 覆盖聊天室顶部管理菜单对应接口的权限校验与关键行为。
*/
class AdminCommandControllerTest extends TestCase
{
use RefreshDatabase;
/**
* 每个测试前清空 Redis,避免跨用例的聊天室消息污染断言。
*/
protected function setUp(): void
{
parent::setUp();
Redis::flushall();
}
/**
* 测试拥有全屏特效权限的职务用户可以触发全部新增全屏特效。
*/
public function test_position_user_can_trigger_all_new_effect_types(): void
{
$admin = $this->createPositionedManager([
PositionPermissionRegistry::ROOM_FULLSCREEN_EFFECT,
]);
$room = Room::create([
'room_name' => '特效房',
]);
$this->joinRoom($admin, $room);
$types = ['sakura', 'meteors', 'gold-rain', 'hearts', 'confetti', 'fireflies'];
foreach ($types as $type) {
$response = $this->actingAs($admin)->postJson(route('command.effect'), [
'room_id' => $room->id,
'type' => $type,
]);
$response->assertOk();
$response->assertJson([
'status' => 'success',
]);
}
}
/**
* 测试拥有全屏特效权限的职务用户可以触发特效。
*/
public function test_position_user_with_fullscreen_effect_permission_can_trigger_effect(): void
{
$admin = $this->createPositionedManager([
PositionPermissionRegistry::ROOM_FULLSCREEN_EFFECT,
]);
$room = Room::create([
'room_name' => '特效权限房',
]);
$this->joinRoom($admin, $room);
$response = $this->actingAs($admin)->postJson(route('command.effect'), [
'room_id' => $room->id,
'type' => 'fireworks',
]);
$response->assertOk()->assertJson([
'status' => 'success',
]);
}
/**
* 测试高等级但无全屏特效权限的用户会被拒绝。
*/
public function test_high_level_user_without_fullscreen_effect_permission_cannot_trigger_effect(): void
{
$admin = User::factory()->create([
'user_level' => 100,
]);
$room = Room::create([
'room_name' => '无权限特效房',
]);
$response = $this->actingAs($admin)->postJson(route('command.effect'), [
'room_id' => $room->id,
'type' => 'fireworks',
]);
$response->assertStatus(403);
}
/**
* 测试拥有公屏讲话权限的职务用户可以发送公屏公告。
*/
public function test_position_user_with_public_broadcast_permission_can_announce(): void
{
Queue::fake();
$admin = $this->createPositionedManager([
PositionPermissionRegistry::ROOM_PUBLIC_BROADCAST,
]);
$admin->load('activePosition.position.department');
$room = Room::create([
'room_name' => '公屏权限房',
]);
$this->joinRoom($admin, $room);
$response = $this->actingAs($admin)->postJson(route('command.announce'), [
'room_id' => $room->id,
'content' => '今晚八点准时集合',
]);
$response->assertOk()->assertJson([
'status' => 'success',
]);
$messages = Redis::lrange("room:{$room->id}:messages", 0, -1);
$publicMessage = collect($messages)
->map(fn (string $item) => json_decode($item, true))
->first(fn (array $item) => ($item['from_user'] ?? null) === '系统公告'
&& str_contains((string) ($item['content'] ?? ''), '今晚八点准时集合'));
$this->assertNotNull($publicMessage);
$this->assertStringContainsString(
$admin->activePosition->position->department->name.'·'.$admin->activePosition->position->name,
(string) $publicMessage['content']
);
$this->assertStringContainsString(
"{$admin->username}</b> 发布公告",
(string) $publicMessage['content']
);
}
/**
* 测试拥有全员清屏权限的职务用户可以清空房间普通消息。
*/
public function test_position_user_with_clear_screen_permission_can_clear_room_messages(): void
{
$admin = $this->createPositionedManager([
PositionPermissionRegistry::ROOM_CLEAR_SCREEN,
]);
$room = Room::create([
'room_name' => '清屏权限房',
]);
$this->joinRoom($admin, $room);
Redis::rpush("room:{$room->id}:messages", json_encode([
'id' => 1,
'content' => '待清除的消息',
], JSON_UNESCAPED_UNICODE));
$response = $this->actingAs($admin)->postJson(route('command.clear_screen'), [
'room_id' => $room->id,
]);
$response->assertOk()->assertJson([
'status' => 'success',
]);
$this->assertSame([], Redis::lrange("room:{$room->id}:messages", 0, -1));
}
/**
* 测试站长可以触发当前房间全员刷新事件。
*/
public function test_site_owner_can_request_refresh_for_all_users_in_room(): void
{
Event::fake([BrowserRefreshRequested::class]);
$admin = User::factory()->create([
'id' => 1,
'user_level' => 100,
]);
$room = Room::create([
'room_name' => '刷新房',
]);
$this->joinRoom($admin, $room);
$response = $this->actingAs($admin)->postJson(route('command.refresh_all'), [
'room_id' => $room->id,
'reason' => '功能更新,要求刷新',
]);
$response->assertOk()->assertJson([
'status' => 'success',
]);
Event::assertDispatched(BrowserRefreshRequested::class, function (BrowserRefreshRequested $event) use ($room, $admin) {
return $event->roomId === $room->id
&& $event->operator === $admin->username
&& $event->reason === '功能更新,要求刷新';
});
}
/**
* 测试非站长用户不能触发全员刷新。
*/
public function test_non_site_owner_cannot_request_refresh_for_all_users(): void
{
Event::fake([BrowserRefreshRequested::class]);
$admin = $this->createPositionedManager([
PositionPermissionRegistry::ROOM_CLEAR_SCREEN,
]);
$room = Room::create([
'room_name' => '无权刷新房',
]);
$response = $this->actingAs($admin)->postJson(route('command.refresh_all'), [
'room_id' => $room->id,
]);
$response->assertStatus(403);
Event::assertNotDispatched(BrowserRefreshRequested::class);
}
/**
* 测试管理操作中的奖励金币会给接收方写入带右下角提示的私聊消息。
*/
public function test_reward_gold_message_contains_toast_notification_for_receiver(): void
{
Queue::fake();
// 站长账号要求 id=1,才能走无职务限制的超管奖励路径。
$admin = User::factory()->create([
'id' => 1,
'user_level' => 100,
]);
$target = User::factory()->create([
'jjb' => 50,
]);
$room = Room::create([
'room_name' => '奖励金币房',
]);
$this->joinRoom($admin, $room);
$this->joinRoom($target, $room);
$response = $this->actingAs($admin)->postJson(route('command.reward'), [
'username' => $target->username,
'room_id' => $room->id,
'amount' => 66,
]);
$response->assertOk()->assertJson([
'status' => 'success',
'message' => "已向 {$target->username} 发放 66 金币奖励 🎉",
]);
// 奖励金额必须真实到账,不能只有提示没有入账。
$this->assertSame(116, (int) $target->fresh()->jjb);
$messages = Redis::lrange("room:{$room->id}:messages", 0, -1);
$privateMessage = collect($messages)
->map(fn (string $item) => json_decode($item, true))
->first(fn (array $item) => ($item['from_user'] ?? null) === '系统'
&& ($item['to_user'] ?? null) === $target->username
&& str_contains((string) ($item['content'] ?? ''), '向你发放了 <b>66</b> 枚金币奖励'));
$this->assertNotNull($privateMessage);
$this->assertTrue((bool) ($privateMessage['is_secret'] ?? false));
$this->assertSame('💰 奖励金币到账', $privateMessage['toast_notification']['title'] ?? null);
$this->assertSame('💰', $privateMessage['toast_notification']['icon'] ?? null);
$this->assertSame('#f59e0b', $privateMessage['toast_notification']['color'] ?? null);
// 奖励公告和接收者私信都应进入异步落库队列。
Queue::assertPushed(SaveMessageJob::class, 2);
}
/**
* 测试警告操作会给目标用户写入带 toast 的私聊提示。
*/
public function test_warn_message_contains_toast_notification_for_receiver(): void
{
Queue::fake();
[$admin, $target, $room] = $this->createAdminCommandActors();
$response = $this->actingAs($admin)->postJson(route('command.warn'), [
'username' => $target->username,
'room_id' => $room->id,
'reason' => '请注意发言尺度',
]);
$response->assertOk()->assertJson([
'status' => 'success',
'message' => "已警告 {$target->username}",
]);
$identityText = $admin->activePosition->position->department->name.'·'.$admin->activePosition->position->name;
$privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, "{$identityText}</b> <b>{$admin->username}</b> 警告了你");
$this->assertNotNull($privateMessage);
$this->assertSame('⚠️ 收到警告', $privateMessage['toast_notification']['title'] ?? null);
$this->assertSame('⚠️', $privateMessage['toast_notification']['icon'] ?? null);
$this->assertSame('#f59e0b', $privateMessage['toast_notification']['color'] ?? null);
Queue::assertPushed(SaveMessageJob::class, 2);
}
/**
* 测试禁言操作会给目标用户写入带 toast 的私聊提示。
*/
public function test_mute_message_contains_toast_notification_for_receiver(): void
{
Queue::fake();
[$admin, $target, $room] = $this->createAdminCommandActors();
$response = $this->actingAs($admin)->postJson(route('command.mute'), [
'username' => $target->username,
'room_id' => $room->id,
'duration' => 15,
]);
$response->assertOk()->assertJson([
'status' => 'success',
'message' => "已禁言 {$target->username} 15 分钟",
]);
$this->assertTrue((bool) Redis::exists("mute:{$room->id}:{$target->username}"));
$privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '已将你禁言 15 分钟');
$this->assertNotNull($privateMessage);
$this->assertSame('🔇 已被禁言', $privateMessage['toast_notification']['title'] ?? null);
$this->assertSame('🔇', $privateMessage['toast_notification']['icon'] ?? null);
$this->assertSame('#6366f1', $privateMessage['toast_notification']['color'] ?? null);
Queue::assertPushed(SaveMessageJob::class, 2);
}
/**
* 测试拥有警告权限的职务用户发送的文案会带上部门和职务。
*/
public function test_position_user_with_warn_permission_uses_department_and_position_in_message(): void
{
Queue::fake();
$admin = $this->createPositionedManager([
PositionPermissionRegistry::USER_WARN,
], departmentRank: 95, positionRank: 95);
$admin->load('activePosition.position.department');
$target = User::factory()->create([
'user_level' => 1,
]);
$room = Room::create([
'room_name' => '职务警告房',
]);
$this->joinRoom($admin, $room);
$this->joinRoom($target, $room);
$response = $this->actingAs($admin)->postJson(route('command.warn'), [
'username' => $target->username,
'room_id' => $room->id,
'reason' => '请遵守秩序',
]);
$response->assertOk()->assertJson([
'status' => 'success',
]);
$identityText = $admin->activePosition->position->department->name.'·'.$admin->activePosition->position->name;
$privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, "{$identityText}</b> <b>{$admin->username}</b> 警告了你");
$this->assertNotNull($privateMessage);
}
/**
* 测试踢出操作会给目标用户写入带 toast 的私聊提示。
*/
public function test_kick_message_contains_toast_notification_for_receiver(): void
{
Queue::fake();
[$admin, $target, $room] = $this->createAdminCommandActors();
Redis::hset("room:{$room->id}:users", $target->username, json_encode(['user_id' => $target->id], JSON_UNESCAPED_UNICODE));
$response = $this->actingAs($admin)->postJson(route('command.kick'), [
'username' => $target->username,
'room_id' => $room->id,
'reason' => '刷屏',
]);
$response->assertOk()->assertJson([
'status' => 'success',
'message' => "已踢出 {$target->username}",
]);
$privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '已将你踢出聊天室');
$this->assertNotNull($privateMessage);
$this->assertSame('🚫 已被踢出', $privateMessage['toast_notification']['title'] ?? null);
$this->assertSame('🚫', $privateMessage['toast_notification']['icon'] ?? null);
$this->assertSame('#ef4444', $privateMessage['toast_notification']['color'] ?? null);
Queue::assertPushed(SaveMessageJob::class, 2);
}
/**
* 测试冻结操作会给目标用户写入带 toast 的私聊提示。
*/
public function test_freeze_message_contains_toast_notification_for_receiver(): void
{
Queue::fake();
[$admin, $target, $room] = $this->createAdminCommandActors();
$response = $this->actingAs($admin)->postJson(route('command.freeze'), [
'username' => $target->username,
'room_id' => $room->id,
'reason' => '严重违规',
]);
$response->assertOk()->assertJson([
'status' => 'success',
'message' => "已冻结 {$target->username} 的账号",
]);
$this->assertSame(-1, (int) $target->fresh()->user_level);
$privateMessage = $this->findPrivateSystemMessage($room->id, $target->username, '已冻结你的账号');
$this->assertNotNull($privateMessage);
$this->assertSame('🧊 账号已冻结', $privateMessage['toast_notification']['title'] ?? null);
$this->assertSame('🧊', $privateMessage['toast_notification']['icon'] ?? null);
$this->assertSame('#3b82f6', $privateMessage['toast_notification']['color'] ?? null);
Queue::assertPushed(SaveMessageJob::class, 2);
}
/**
* 测试缺少踢出权限的职务用户会被拒绝。
*/
public function test_position_user_without_kick_permission_cannot_kick_target(): void
{
$admin = $this->createPositionedManager([
PositionPermissionRegistry::USER_WARN,
]);
$target = User::factory()->create([
'user_level' => 1,
]);
$room = Room::create([
'room_name' => '无权踢人房',
]);
$this->joinRoom($admin, $room);
$response = $this->actingAs($admin)->postJson(route('command.kick'), [
'username' => $target->username,
'room_id' => $room->id,
'reason' => '测试',
]);
$response->assertStatus(403)->assertJson([
'status' => 'error',
'message' => '当前职务无权踢出用户',
]);
}
/**
* 测试不能处理部门位阶更高的目标用户。
*/
public function test_position_user_cannot_warn_target_from_higher_rank_department(): void
{
$admin = $this->createPositionedManager([
PositionPermissionRegistry::USER_WARN,
], departmentRank: 80, positionRank: 80);
$target = $this->createTargetUserWithPositionRanks(departmentRank: 90, positionRank: 70);
$room = Room::create([
'room_name' => '高部门位阶房',
]);
$this->joinRoom($admin, $room);
$response = $this->actingAs($admin)->postJson(route('command.warn'), [
'username' => $target->username,
'room_id' => $room->id,
'reason' => '测试',
]);
$response->assertStatus(403)->assertJson([
'status' => 'error',
'message' => '不能处理职务高于自己的用户',
]);
}
/**
* 测试同部门下不能处理职务位阶更高的目标用户。
*/
public function test_position_user_cannot_mute_target_with_higher_rank_in_same_department(): void
{
$department = Department::create([
'name' => '同部门位阶测试',
'rank' => 88,
'color' => '#0f766e',
'sort_order' => 1,
'description' => '同部门位阶限制测试',
]);
$admin = $this->createPositionedManager([
PositionPermissionRegistry::USER_MUTE,
], departmentRank: 88, positionRank: 60, existingDepartment: $department);
$target = $this->createTargetUserWithPositionRanks(departmentRank: 88, positionRank: 70, existingDepartment: $department);
$room = Room::create([
'room_name' => '同部门高位阶房',
]);
$this->joinRoom($admin, $room);
$response = $this->actingAs($admin)->postJson(route('command.mute'), [
'username' => $target->username,
'room_id' => $room->id,
'duration' => 10,
]);
$response->assertStatus(403)->assertJson([
'status' => 'error',
'message' => '不能处理职务高于自己的用户',
]);
}
/**
* 测试有命令权限但未进入目标房间时不能跨房间触发特效。
*/
public function test_position_user_cannot_run_room_command_without_joining_room(): void
{
$admin = $this->createPositionedManager([
PositionPermissionRegistry::ROOM_FULLSCREEN_EFFECT,
]);
$room = Room::create([
'room_name' => '未进房特效房',
]);
$response = $this->actingAs($admin)->postJson(route('command.effect'), [
'room_id' => $room->id,
'type' => 'fireworks',
]);
$response->assertStatus(403)->assertJson([
'status' => 'error',
'message' => '请先进入该房间后再执行管理命令',
]);
}
/**
* 测试目标用户不在当前房间时不能执行用户管理动作。
*/
public function test_position_user_cannot_moderate_target_outside_current_room(): void
{
$admin = $this->createPositionedManager([
PositionPermissionRegistry::USER_WARN,
]);
$target = User::factory()->create([
'user_level' => 1,
]);
$room = Room::create([
'room_name' => '目标离线房',
]);
$this->joinRoom($admin, $room);
$response = $this->actingAs($admin)->postJson(route('command.warn'), [
'username' => $target->username,
'room_id' => $room->id,
'reason' => '测试',
]);
$response->assertStatus(403)->assertJson([
'status' => 'error',
'message' => '目标用户不在当前房间,无法执行该操作',
]);
}
/**
* 测试管理员输入的公告与警告理由会被转义后写入系统消息。
*/
public function test_admin_command_content_is_escaped_before_broadcasting(): void
{
Queue::fake();
[$admin, $target, $room] = $this->createAdminCommandActors();
$payload = '<img src=x onerror=alert(1)>';
$this->actingAs($admin)->postJson(route('command.warn'), [
'username' => $target->username,
'room_id' => $room->id,
'reason' => $payload,
])->assertOk();
$messages = Redis::lrange("room:{$room->id}:messages", 0, -1);
$combinedContent = collect($messages)
->map(fn (string $item): string => (string) (json_decode($item, true)['content'] ?? ''))
->implode("\n");
$this->assertStringNotContainsString('<img src=x onerror=alert(1)>', $combinedContent);
$this->assertStringContainsString('&lt;img src=x onerror=alert(1)&gt;', $combinedContent);
}
/**
* 测试没有奖励金币权限的职务,即使配置了额度也不能直接 POST 发奖励。
*/
public function test_position_user_without_reward_permission_cannot_reward_even_with_limit(): void
{
$admin = $this->createPositionedManager([
PositionPermissionRegistry::USER_WARN,
]);
$admin->activePosition->position->update([
'max_reward' => 100,
]);
$target = User::factory()->create([
'user_level' => 1,
]);
$room = Room::create([
'room_name' => '无奖励权限房',
]);
$this->joinRoom($admin, $room);
$this->joinRoom($target, $room);
$response = $this->actingAs($admin)->postJson(route('command.reward'), [
'username' => $target->username,
'room_id' => $room->id,
'amount' => 20,
]);
$response->assertStatus(403)->assertJson([
'status' => 'error',
'message' => '当前职务无权发放奖励',
]);
}
/**
* 创建管理员命令测试共用的操作者、目标用户和房间。
*
* @return array{0: User, 1: User, 2: Room}
*/
private function createAdminCommandActors(): array
{
$admin = $this->createPositionedManager([
PositionPermissionRegistry::USER_WARN,
PositionPermissionRegistry::USER_MUTE,
PositionPermissionRegistry::USER_KICK,
PositionPermissionRegistry::USER_FREEZE,
]);
$admin->load('activePosition.position.department');
$target = User::factory()->create([
'user_level' => 1,
]);
$room = Room::create([
'room_name' => '管理操作房',
]);
$this->joinRoom($admin, $room);
$this->joinRoom($target, $room);
return [$admin, $target, $room];
}
/**
* 创建带指定聊天室权限的职务管理员。
*
* @param list<string> $permissions
*/
private function createPositionedManager(
array $permissions,
int $departmentRank = 90,
int $positionRank = 90,
?Department $existingDepartment = null,
): User {
$user = User::factory()->create([
'user_level' => $positionRank,
]);
$department = $existingDepartment ?? Department::create([
'name' => '命令权限部'.$user->id,
'rank' => $departmentRank,
'color' => '#7c3aed',
'sort_order' => 1,
'description' => '聊天室命令权限测试',
]);
$position = Position::create([
'department_id' => $department->id,
'name' => '命令权限职务'.$user->id,
'icon' => '🛠️',
'rank' => $positionRank,
'level' => $positionRank,
'sort_order' => 1,
'permissions' => $permissions,
]);
UserPosition::create([
'user_id' => $user->id,
'position_id' => $position->id,
'appointed_by_user_id' => null,
'appointed_at' => now(),
'remark' => '聊天室命令权限测试',
'is_active' => true,
]);
return $user->fresh();
}
/**
* 创建带指定部门/职务位阶的目标用户。
*/
private function createTargetUserWithPositionRanks(
int $departmentRank,
int $positionRank,
?Department $existingDepartment = null,
): User {
$user = User::factory()->create([
'user_level' => $positionRank,
]);
$department = $existingDepartment ?? Department::create([
'name' => '目标用户部门'.$user->id,
'rank' => $departmentRank,
'color' => '#1d4ed8',
'sort_order' => 1,
'description' => '目标用户位阶测试',
]);
$position = Position::create([
'department_id' => $department->id,
'name' => '目标用户职务'.$user->id,
'icon' => '🎖️',
'rank' => $positionRank,
'level' => $positionRank,
'sort_order' => 1,
'permissions' => [],
]);
UserPosition::create([
'user_id' => $user->id,
'position_id' => $position->id,
'appointed_by_user_id' => null,
'appointed_at' => now(),
'remark' => '目标用户位阶测试',
'is_active' => true,
]);
return $user->fresh();
}
/**
* 从房间消息缓存中定位目标用户收到的系统私聊提示。
*
* @return array<string, mixed>|null
*/
private function findPrivateSystemMessage(int $roomId, string $targetUsername, string $needle): ?array
{
$messages = Redis::lrange("room:{$roomId}:messages", 0, -1);
return collect($messages)
->map(fn (string $item) => json_decode($item, true))
->first(fn (array $item) => ($item['from_user'] ?? null) === '系统'
&& ($item['to_user'] ?? null) === $targetUsername
&& str_contains((string) ($item['content'] ?? ''), $needle));
}
/**
* 将测试用户写入指定房间在线表,模拟已经进入聊天室的状态。
*/
private function joinRoom(User $user, Room $room): void
{
Redis::hset("room:{$room->id}:users", $user->username, json_encode([
'id' => $user->id,
'username' => $user->username,
], JSON_UNESCAPED_UNICODE));
}
}