php-tenant/tests/Feature/WorkspaceOwnershipTransferTest.php
Claude a07bfb3fd2
feat: add workspace ownership transfer
Add transferOwnership() method to the Workspace model that allows the
current owner to transfer ownership to another existing workspace member.

The method:
- Verifies the new owner is an existing member
- Demotes the current owner to admin role
- Promotes the new owner to owner role
- Updates team assignments when teams are in use
- Wraps the role changes in a DB transaction
- Dispatches WorkspaceOwnershipTransferred event
- Throws WorkspaceOwnershipException for auth/validation failures

New files:
- Events/WorkspaceOwnershipTransferred.php
- Exceptions/WorkspaceOwnershipException.php
- tests/Feature/WorkspaceOwnershipTransferTest.php

Fixes #35

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:56:18 +00:00

250 lines
8 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Tenant\Tests\Feature;
use Core\Tenant\Events\WorkspaceOwnershipTransferred;
use Core\Tenant\Exceptions\WorkspaceOwnershipException;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Models\WorkspaceTeam;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
class WorkspaceOwnershipTransferTest extends TestCase
{
use RefreshDatabase;
public function test_owner_can_transfer_ownership_to_member(): void
{
Event::fake();
$owner = User::factory()->create();
$member = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($owner->id, ['role' => 'owner']);
$workspace->users()->attach($member->id, ['role' => 'member']);
$workspace->transferOwnership($member, $owner);
// Previous owner should now be admin
$this->assertEquals(
'admin',
$workspace->members()->where('user_id', $owner->id)->first()->role
);
// New owner should now be owner
$this->assertEquals(
'owner',
$workspace->members()->where('user_id', $member->id)->first()->role
);
Event::assertDispatched(WorkspaceOwnershipTransferred::class, function ($event) use ($workspace, $owner, $member) {
return $event->workspace->id === $workspace->id
&& $event->previousOwner->id === $owner->id
&& $event->newOwner->id === $member->id;
});
}
public function test_owner_can_transfer_ownership_to_admin(): void
{
Event::fake();
$owner = User::factory()->create();
$admin = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($owner->id, ['role' => 'owner']);
$workspace->users()->attach($admin->id, ['role' => 'admin']);
$workspace->transferOwnership($admin, $owner);
$this->assertEquals(
'admin',
$workspace->members()->where('user_id', $owner->id)->first()->role
);
$this->assertEquals(
'owner',
$workspace->members()->where('user_id', $admin->id)->first()->role
);
}
public function test_transfer_without_acting_user_skips_auth_check(): void
{
Event::fake();
$owner = User::factory()->create();
$member = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($owner->id, ['role' => 'owner']);
$workspace->users()->attach($member->id, ['role' => 'member']);
// No acting user passed - should succeed (for programmatic use)
$workspace->transferOwnership($member);
$this->assertEquals(
'owner',
$workspace->members()->where('user_id', $member->id)->first()->role
);
}
public function test_non_owner_cannot_transfer_ownership(): void
{
$owner = User::factory()->create();
$admin = User::factory()->create();
$member = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($owner->id, ['role' => 'owner']);
$workspace->users()->attach($admin->id, ['role' => 'admin']);
$workspace->users()->attach($member->id, ['role' => 'member']);
$this->expectException(WorkspaceOwnershipException::class);
$this->expectExceptionCode(403);
$workspace->transferOwnership($member, $admin);
}
public function test_cannot_transfer_to_non_member(): void
{
$owner = User::factory()->create();
$nonMember = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($owner->id, ['role' => 'owner']);
$this->expectException(WorkspaceOwnershipException::class);
$this->expectExceptionCode(422);
$workspace->transferOwnership($nonMember, $owner);
}
public function test_cannot_transfer_to_self(): void
{
$owner = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($owner->id, ['role' => 'owner']);
$this->expectException(WorkspaceOwnershipException::class);
$this->expectExceptionCode(422);
$workspace->transferOwnership($owner, $owner);
}
public function test_transfer_updates_team_assignments(): void
{
Event::fake();
$owner = User::factory()->create();
$member = User::factory()->create();
$workspace = Workspace::factory()->create();
// Create system teams
$ownerTeam = WorkspaceTeam::create([
'workspace_id' => $workspace->id,
'name' => 'Owner',
'slug' => WorkspaceTeam::TEAM_OWNER,
'is_system' => true,
'colour' => 'violet',
'sort_order' => 1,
]);
$adminTeam = WorkspaceTeam::create([
'workspace_id' => $workspace->id,
'name' => 'Admin',
'slug' => WorkspaceTeam::TEAM_ADMIN,
'is_system' => true,
'colour' => 'blue',
'sort_order' => 2,
]);
$memberTeam = WorkspaceTeam::create([
'workspace_id' => $workspace->id,
'name' => 'Member',
'slug' => WorkspaceTeam::TEAM_MEMBER,
'is_system' => true,
'is_default' => true,
'colour' => 'emerald',
'sort_order' => 3,
]);
// Attach users with teams
$workspace->users()->attach($owner->id, [
'role' => 'owner',
'team_id' => $ownerTeam->id,
]);
$workspace->users()->attach($member->id, [
'role' => 'member',
'team_id' => $memberTeam->id,
]);
$workspace->transferOwnership($member, $owner);
// Previous owner should be in admin team
$ownerMembership = $workspace->members()->where('user_id', $owner->id)->first();
$this->assertEquals('admin', $ownerMembership->role);
$this->assertEquals($adminTeam->id, $ownerMembership->team_id);
// New owner should be in owner team
$memberMembership = $workspace->members()->where('user_id', $member->id)->first();
$this->assertEquals('owner', $memberMembership->role);
$this->assertEquals($ownerTeam->id, $memberMembership->team_id);
}
public function test_transfer_dispatches_event(): void
{
Event::fake();
$owner = User::factory()->create();
$member = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($owner->id, ['role' => 'owner']);
$workspace->users()->attach($member->id, ['role' => 'member']);
$workspace->transferOwnership($member, $owner);
Event::assertDispatched(WorkspaceOwnershipTransferred::class, 1);
}
public function test_failed_transfer_does_not_dispatch_event(): void
{
Event::fake();
$owner = User::factory()->create();
$nonMember = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($owner->id, ['role' => 'owner']);
try {
$workspace->transferOwnership($nonMember, $owner);
} catch (WorkspaceOwnershipException) {
// Expected
}
Event::assertNotDispatched(WorkspaceOwnershipTransferred::class);
}
public function test_transfer_returns_workspace_instance(): void
{
Event::fake();
$owner = User::factory()->create();
$member = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($owner->id, ['role' => 'owner']);
$workspace->users()->attach($member->id, ['role' => 'member']);
$result = $workspace->transferOwnership($member, $owner);
$this->assertInstanceOf(Workspace::class, $result);
$this->assertEquals($workspace->id, $result->id);
}
}