dootask/tests/Feature/MultiOwnerGroupTest.php
kuaifan 24710289e1 feat(multi-owner): 群/项目/部门支持主+副双负责人体系
- 群组:新增 web_socket_dialog_users.role(1=主、2=副),主可任命/罢免副群主,副可邀请/移出普通成员
- 项目:project_users.owner 扩展为 0/1/2(成员/主/副),主独占转让和删除,副共享日常管理;任务可见性、通知、分配等下游逻辑统一用「主+副」
- 部门:新增 user_department_owners 表存储副负责人;部门群同步副群主,赋予群管理员权限
- 转移用户时副身份不替补、降级为普通成员
- 配套 migration/backfill、API、前端 UI、i18n 词条与三项 Feature 测试
- .gitignore 忽略 .playwright-mcp/
2026-05-03 00:05:31 +00:00

514 lines
21 KiB
PHP
Raw 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\Models\User;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogUser;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
class MultiOwnerGroupTest extends TestCase
{
use DatabaseTransactions;
private function makeUser(string $email): User
{
$user = User::createInstance([
'email' => $email,
'userimg' => '',
'nickname' => 'TestUser',
'profession' => '',
'password' => md5('123456'),
]);
$user->save();
return $user;
}
private function makeGroup(int $ownerUserid, array $memberUserids = []): WebSocketDialog
{
$allMembers = array_unique(array_merge([$ownerUserid], $memberUserids));
return WebSocketDialog::createGroup('TestGroup', $allMembers, 'user', $ownerUserid);
}
public function test_isPrimaryOwner_returns_true_only_for_owner()
{
$owner = $this->makeUser('owner@test.local');
$member = $this->makeUser('member@test.local');
$dialog = $this->makeGroup($owner->userid, [$member->userid]);
$this->assertTrue($dialog->isPrimaryOwner($owner->userid));
$this->assertFalse($dialog->isPrimaryOwner($member->userid));
}
public function test_isOwner_includes_primary_and_deputy()
{
$owner = $this->makeUser('o2@test.local');
$deputy = $this->makeUser('d2@test.local');
$member = $this->makeUser('m2@test.local');
$dialog = $this->makeGroup($owner->userid, [$deputy->userid, $member->userid]);
// 手工把 deputy 设为副群主
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $deputy->userid)
->update(['role' => 2]);
$this->assertTrue($dialog->isOwner($owner->userid));
$this->assertTrue($dialog->isOwner($deputy->userid));
$this->assertFalse($dialog->isOwner($member->userid));
$this->assertTrue($dialog->isDeputyOwner($deputy->userid));
$this->assertFalse($dialog->isDeputyOwner($owner->userid));
$this->assertFalse($dialog->isDeputyOwner($member->userid));
}
public function test_deputy_ids_returns_array_of_role_2_userids()
{
$owner = $this->makeUser('o3@test.local');
$d1 = $this->makeUser('d31@test.local');
$d2 = $this->makeUser('d32@test.local');
$m = $this->makeUser('m3@test.local');
$dialog = $this->makeGroup($owner->userid, [$d1->userid, $d2->userid, $m->userid]);
WebSocketDialogUser::where('dialog_id', $dialog->id)
->whereIn('userid', [$d1->userid, $d2->userid])
->update(['role' => 2]);
$deputyIds = $dialog->deputy_ids;
sort($deputyIds);
$expected = [$d1->userid, $d2->userid];
sort($expected);
$this->assertEquals($expected, $deputyIds);
$this->assertNotContains($owner->userid, $deputyIds);
$this->assertNotContains($m->userid, $deputyIds);
}
public function test_createGroup_sets_owner_role_to_1()
{
$owner = $this->makeUser('o5@test.local');
$m = $this->makeUser('m5@test.local');
$dialog = WebSocketDialog::createGroup('G5', [$owner->userid, $m->userid], 'user', $owner->userid);
$ownerRole = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $owner->userid)
->value('role');
$memberRole = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $m->userid)
->value('role');
$this->assertEquals(1, (int)$ownerRole);
$this->assertEquals(0, (int)$memberRole);
}
public function test_exitGroup_removes_deputy_role_along_with_membership()
{
$owner = $this->makeUser('o6@test.local');
$deputy = $this->makeUser('d6@test.local');
$dialog = $this->makeGroup($owner->userid, [$deputy->userid]);
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $deputy->userid)
->update(['role' => 2]);
// 模拟副群主退群pushMsg=false 跳过 Swoole 推送,仅验证 DB 状态)
$dialog->exitGroup($deputy->userid, 'exit', false, false);
$exists = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $deputy->userid)
->exists();
$this->assertFalse($exists, '退群后副群主记录应被删除');
$this->assertNotContains($deputy->userid, $dialog->fresh()->deputy_ids);
}
public function test_joinGroup_defaults_role_to_0()
{
$owner = $this->makeUser('o6b@test.local');
$newbie = $this->makeUser('n6b@test.local');
$dialog = $this->makeGroup($owner->userid);
$dialog->joinGroup($newbie->userid, $owner->userid, null, false);
$role = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $newbie->userid)
->value('role');
$this->assertEquals(0, (int)$role);
}
/**
* 直接执行 group__transfer 的角色同步逻辑model-level绕过 HTTP/Swoole/Auth
* HTTP 测试在此项目无法工作User::auth() 依赖 Doo::userId() 与 RequestContext
* 不兼容 Laravel 标准 auth guard
*/
private function simulateTransfer(WebSocketDialog $dialog, int $newOwnerId): void
{
$oldOwnerId = (int)$dialog->owner_id;
$dialog->owner_id = $newOwnerId;
$dialog->save();
$dialog->joinGroup($newOwnerId, 0, null, false);
// 同步 role原主 role=0、新主 role=1覆盖即可
if ($oldOwnerId > 0 && $oldOwnerId !== $newOwnerId) {
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $oldOwnerId)
->update(['role' => 0]);
}
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $newOwnerId)
->update(['role' => 1]);
}
public function test_group_transfer_updates_role_for_old_and_new_owner()
{
$oldOwner = $this->makeUser('o7@test.local');
$newOwner = $this->makeUser('n7@test.local');
$dialog = $this->makeGroup($oldOwner->userid, [$newOwner->userid]);
$this->simulateTransfer($dialog, $newOwner->userid);
$oldRole = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $oldOwner->userid)->value('role');
$newRole = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $newOwner->userid)->value('role');
$this->assertEquals(0, (int)$oldRole, '原主应降为普通成员');
$this->assertEquals(1, (int)$newRole, '新主 role 应为 1');
}
public function test_group_transfer_preserves_deputies()
{
$oldOwner = $this->makeUser('o7b@test.local');
$newOwner = $this->makeUser('n7b@test.local');
$deputy = $this->makeUser('d7b@test.local');
$dialog = $this->makeGroup($oldOwner->userid, [$newOwner->userid, $deputy->userid]);
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $deputy->userid)->update(['role' => 2]);
$this->simulateTransfer($dialog, $newOwner->userid);
$this->assertContains($deputy->userid, $dialog->fresh()->deputy_ids);
}
private function simulateAddDeputy(WebSocketDialog $dialog, int $userid)
{
if ($userid <= 0) {
return ['ret' => 0, 'msg' => '请选择有效的成员'];
}
// Note: skip checkDialog/checkGroup auth — assume caller is primary owner
$member = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $userid)
->first();
if (empty($member)) {
return ['ret' => 0, 'msg' => '该用户不是群成员'];
}
if ((int)$member->role === 1) {
return ['ret' => 0, 'msg' => '不能将群主任命为群管理员'];
}
if ((int)$member->role !== 2) {
$member->role = 2;
$member->save();
}
return ['ret' => 1, 'msg' => '任命成功'];
}
public function test_adddeputy_target_must_be_member()
{
$owner = $this->makeUser('o8b@test.local');
$outsider = $this->makeUser('out8b@test.local');
$dialog = $this->makeGroup($owner->userid);
$result = $this->simulateAddDeputy($dialog, $outsider->userid);
$this->assertEquals(0, $result['ret']);
}
public function test_adddeputy_cannot_promote_primary_owner()
{
$owner = $this->makeUser('o8c@test.local');
$dialog = $this->makeGroup($owner->userid);
$result = $this->simulateAddDeputy($dialog, $owner->userid);
$this->assertEquals(0, $result['ret']);
}
public function test_adddeputy_sets_role_to_2_for_normal_member()
{
$owner = $this->makeUser('o8d@test.local');
$member = $this->makeUser('m8d@test.local');
$dialog = $this->makeGroup($owner->userid, [$member->userid]);
$result = $this->simulateAddDeputy($dialog, $member->userid);
$this->assertEquals(1, $result['ret']);
$role = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $member->userid)->value('role');
$this->assertEquals(2, (int)$role);
$this->assertContains($member->userid, $dialog->fresh()->deputy_ids);
}
public function test_adddeputy_idempotent()
{
$owner = $this->makeUser('o8e@test.local');
$member = $this->makeUser('m8e@test.local');
$dialog = $this->makeGroup($owner->userid, [$member->userid]);
$this->simulateAddDeputy($dialog, $member->userid);
$result = $this->simulateAddDeputy($dialog, $member->userid); // 第二次
$this->assertEquals(1, $result['ret']);
// 应该只有一条 role=2 记录
$count = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $member->userid)
->where('role', 2)
->count();
$this->assertEquals(1, $count);
}
private function simulateDelDeputy(WebSocketDialog $dialog, int $userid)
{
if ($userid <= 0) {
return ['ret' => 0, 'msg' => '请选择有效的成员'];
}
$member = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $userid)
->first();
if (empty($member)) {
return ['ret' => 1, 'msg' => '罢免成功']; // 幂等
}
if ((int)$member->role === 2) {
$member->role = 0;
$member->save();
}
return ['ret' => 1, 'msg' => '罢免成功'];
}
public function test_deldeputy_demotes_role_2_to_0()
{
$owner = $this->makeUser('o9@test.local');
$deputy = $this->makeUser('d9@test.local');
$dialog = $this->makeGroup($owner->userid, [$deputy->userid]);
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $deputy->userid)
->update(['role' => 2]);
$result = $this->simulateDelDeputy($dialog, $deputy->userid);
$this->assertEquals(1, $result['ret']);
$role = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $deputy->userid)->value('role');
$this->assertEquals(0, (int)$role, '罢免后应降为普通成员');
$this->assertNotContains($deputy->userid, $dialog->fresh()->deputy_ids);
}
public function test_deldeputy_idempotent_for_non_deputy()
{
$owner = $this->makeUser('o9b@test.local');
$member = $this->makeUser('m9b@test.local');
$dialog = $this->makeGroup($owner->userid, [$member->userid]);
// 普通成员role=0调用罢免应幂等返回成功
$result = $this->simulateDelDeputy($dialog, $member->userid);
$this->assertEquals(1, $result['ret']);
// 角色仍是 0
$role = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $member->userid)->value('role');
$this->assertEquals(0, (int)$role);
}
public function test_deldeputy_does_not_affect_primary_owner()
{
$owner = $this->makeUser('o9c@test.local');
$dialog = $this->makeGroup($owner->userid);
// 试图对主群主调用罢免(不应改变其 role=1
$result = $this->simulateDelDeputy($dialog, $owner->userid);
$this->assertEquals(1, $result['ret']); // 幂等返回成功
$role = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $owner->userid)->value('role');
$this->assertEquals(1, (int)$role, '主群主 role 不应被罢免接口改变');
}
/**
* 模拟 exitGroup 的权限检查逻辑(与 WebSocketDialog::exitGroup 中的 checkDelete 块保持一致)。
* 用于在无 Swoole RequestContext 的 PHPUnit 环境中直接验证权限规则,而无需模拟 User::userid()。
*
* @param WebSocketDialog $dialog
* @param int $actorId 执行操作的用户 ID
* @param int $targetId 被移出的用户 ID
* @return array ['allowed' => bool, 'error' => string|null]
*/
private function simulateRemovePermission(WebSocketDialog $dialog, int $actorId, int $targetId): array
{
if ($dialog->group_type === 'all') {
return ['allowed' => false, 'error' => '仅管理员可操作全员群'];
}
// 未认证时拒绝
if ($actorId <= 0) {
return ['allowed' => false, 'error' => '只有群主或邀请人可以移出成员'];
}
// 获取目标成员记录
$item = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $targetId)->first();
if (!$item) {
return ['allowed' => false, 'error' => '目标用户不在群内'];
}
// 主群主、副群主、邀请人可移出
$allowedActor = $dialog->isOwner($actorId) || $actorId === (int)$item->inviter;
if (!$allowedActor) {
return ['allowed' => false, 'error' => '只有群主或邀请人可以移出成员'];
}
// 副群主不能移出主群主或其他副群主
if ($dialog->isDeputyOwner($actorId)) {
$targetIsOwner = $dialog->isPrimaryOwner($targetId) || $dialog->isDeputyOwner($targetId);
if ($targetIsOwner) {
return ['allowed' => false, 'error' => '群管理员不能移出群主或其他群管理员'];
}
}
// 群主不可被移出(额外保障,与 exitGroup 行为一致)
if ($targetId == $dialog->owner_id) {
return ['allowed' => false, 'error' => '群主不可移出'];
}
return ['allowed' => true, 'error' => null];
}
public function test_deputy_can_remove_normal_member()
{
$owner = $this->makeUser('o10@test.local');
$deputy = $this->makeUser('d10@test.local');
$member = $this->makeUser('m10@test.local');
$dialog = $this->makeGroup($owner->userid, [$deputy->userid, $member->userid]);
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $deputy->userid)->update(['role' => 2]);
// 验证权限逻辑:副群主可移出普通成员
$result = $this->simulateRemovePermission($dialog, $deputy->userid, $member->userid);
$this->assertTrue($result['allowed'], '副群主应能移出普通成员,错误:' . ($result['error'] ?? ''));
// 验证实际移出操作checkDelete=false 绕过 auth直接测试 DB 状态)
$dialog->exitGroup($member->userid, 'remove', false, false);
$exists = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $member->userid)->exists();
$this->assertFalse($exists, '移出后成员记录应不存在');
}
public function test_deputy_cannot_remove_primary_owner()
{
$owner = $this->makeUser('o10b@test.local');
$deputy = $this->makeUser('d10b@test.local');
$dialog = $this->makeGroup($owner->userid, [$deputy->userid]);
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $deputy->userid)->update(['role' => 2]);
// 验证权限逻辑:副群主不可移出主群主
$result = $this->simulateRemovePermission($dialog, $deputy->userid, $owner->userid);
$this->assertFalse($result['allowed'], '副群主不应能移出主群主');
$this->assertNotNull($result['error']);
}
public function test_deputy_cannot_remove_other_deputy()
{
$owner = $this->makeUser('o10c@test.local');
$deputy1 = $this->makeUser('d10c1@test.local');
$deputy2 = $this->makeUser('d10c2@test.local');
$dialog = $this->makeGroup($owner->userid, [$deputy1->userid, $deputy2->userid]);
WebSocketDialogUser::where('dialog_id', $dialog->id)
->whereIn('userid', [$deputy1->userid, $deputy2->userid])
->update(['role' => 2]);
// 验证权限逻辑:副群主不可移出其他副群主
$result = $this->simulateRemovePermission($dialog, $deputy1->userid, $deputy2->userid);
$this->assertFalse($result['allowed'], '副群主不应能移出其他副群主');
$this->assertEquals('群管理员不能移出群主或其他群管理员', $result['error']);
}
public function test_inviter_can_still_remove_invitee()
{
$owner = $this->makeUser('o10d@test.local');
$inviter = $this->makeUser('inv10d@test.local');
$invitee = $this->makeUser('iv10d@test.local');
$dialog = $this->makeGroup($owner->userid, [$inviter->userid, $invitee->userid]);
// 设置 invitee 的 inviter 字段
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $invitee->userid)
->update(['inviter' => $inviter->userid]);
// 验证权限逻辑:邀请人可移出被邀请者
$result = $this->simulateRemovePermission($dialog, $inviter->userid, $invitee->userid);
$this->assertTrue($result['allowed'], '邀请人应能移出被邀请者,错误:' . ($result['error'] ?? ''));
// 验证实际移出
$dialog->exitGroup($invitee->userid, 'remove', false, false);
$exists = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $invitee->userid)->exists();
$this->assertFalse($exists, '邀请人移出后被邀请者记录应不存在');
}
public function test_non_owner_non_inviter_cannot_remove_member()
{
$owner = $this->makeUser('o10e@test.local');
$member1 = $this->makeUser('m10e1@test.local');
$member2 = $this->makeUser('m10e2@test.local');
$dialog = $this->makeGroup($owner->userid, [$member1->userid, $member2->userid]);
// 普通成员无法移出其他成员
$result = $this->simulateRemovePermission($dialog, $member1->userid, $member2->userid);
$this->assertFalse($result['allowed'], '普通成员不应能移出其他成员');
}
/**
* 验证离职移交时副群主角色被正确清除。
*
* UserTransfer::exitDialog() 对离职用户调用 exitGroup($original_userid, 'remove', false, false)
* exitGroup 内部直接 hard-delete web_socket_dialog_users 记录($item->delete()
* 因此副群主的 role 随记录一起消失,无需额外逻辑。
*
* 本测试直接调用 exitDialog()(通过 UserTransfer 实例),绕过 start() 中的项目/任务/文件迁移,
* 以确保在无 Swoole 推送的 PHPUnit 环境中可以正常运行。
*/
public function test_user_transfer_clears_deputy_role()
{
$owner = $this->makeUser('o11@test.local');
$departing = $this->makeUser('dep11@test.local');
$receiver = $this->makeUser('rec11@test.local');
$dialog = $this->makeGroup($owner->userid, [$departing->userid, $receiver->userid]);
// 将离职用户设为副群主
WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $departing->userid)
->update(['role' => 2]);
// 验证前置条件
$this->assertContains($departing->userid, $dialog->fresh()->deputy_ids, '前置条件:离职用户应是副群主');
// 通过 UserTransfer 触发 exitDialog使用正确字段名 original_userid / new_userid
$transfer = \App\Models\UserTransfer::createInstance([
'original_userid' => $departing->userid,
'new_userid' => $receiver->userid,
]);
$transfer->save();
$transfer->exitDialog();
$freshDialog = $dialog->fresh();
// 离职用户不应再出现在副群主列表中
$this->assertNotContains($departing->userid, $freshDialog->deputy_ids, '离职用户不应留在副群主列表');
// 离职用户的成员记录应已删除
$exists = WebSocketDialogUser::where('dialog_id', $dialog->id)
->where('userid', $departing->userid)
->exists();
$this->assertFalse($exists, 'exitDialog 后离职用户的成员记录应被删除');
// 接收方不应自动继承副群主角色
$this->assertNotContains($receiver->userid, $freshDialog->deputy_ids, '接收方不应自动继承副群主角色');
}
}