diff --git a/tests/Feature/WorkspaceControllerTest.php b/tests/Feature/WorkspaceControllerTest.php new file mode 100644 index 0000000..eda5c1c --- /dev/null +++ b/tests/Feature/WorkspaceControllerTest.php @@ -0,0 +1,903 @@ +string('account_type')->default('apollo')->after('remember_token'); + }); + } + + // Create users + $this->owner = User::factory()->create(['name' => 'Owner User']); + $this->admin = User::factory()->create(['name' => 'Admin User']); + $this->member = User::factory()->create(['name' => 'Member User']); + $this->outsider = User::factory()->create(['name' => 'Outsider User']); + + // Create workspaces + $this->workspace = Workspace::factory()->create([ + 'name' => 'Test Workspace', + 'slug' => 'test-workspace', + 'domain' => 'hub.host.uk.com', + 'type' => 'team', + 'is_active' => true, + ]); + + $this->secondWorkspace = Workspace::factory()->create([ + 'name' => 'Second Workspace', + 'slug' => 'second-workspace', + 'domain' => 'hub.host.uk.com', + 'type' => 'personal', + 'is_active' => true, + ]); + + // Attach users with roles + $this->workspace->users()->attach($this->owner->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + $this->workspace->users()->attach($this->admin->id, [ + 'role' => 'admin', + 'is_default' => true, + ]); + $this->workspace->users()->attach($this->member->id, [ + 'role' => 'member', + 'is_default' => true, + ]); + + // Give owner access to second workspace too + $this->secondWorkspace->users()->attach($this->owner->id, [ + 'role' => 'owner', + 'is_default' => false, + ]); + + // Give outsider their own workspace + $outsiderWorkspace = Workspace::factory()->create([ + 'name' => 'Outsider Workspace', + 'domain' => 'hub.host.uk.com', + ]); + $outsiderWorkspace->users()->attach($this->outsider->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + } + + // ========================================================================= + // INDEX - List workspaces + // ========================================================================= + + public function test_user_can_list_their_workspaces(): void + { + $this->actingAs($this->owner); + + $workspaces = $this->owner->workspaces() + ->orderBy('user_workspace.is_default', 'desc') + ->orderBy('workspaces.name', 'asc') + ->get(); + + $this->assertCount(2, $workspaces); + $this->assertTrue( + $workspaces->contains('id', $this->workspace->id) + ); + $this->assertTrue( + $workspaces->contains('id', $this->secondWorkspace->id) + ); + } + + public function test_user_only_sees_own_workspaces(): void + { + $this->actingAs($this->member); + + $workspaces = $this->member->workspaces()->get(); + + // Member only has access to one workspace + $this->assertCount(1, $workspaces); + $this->assertEquals($this->workspace->id, $workspaces->first()->id); + } + + public function test_workspace_list_can_filter_by_type(): void + { + $this->actingAs($this->owner); + + $teamWorkspaces = $this->owner->workspaces() + ->where('type', 'team') + ->get(); + + $this->assertCount(1, $teamWorkspaces); + $this->assertEquals('Test Workspace', $teamWorkspaces->first()->name); + + $personalWorkspaces = $this->owner->workspaces() + ->where('type', 'personal') + ->get(); + + $this->assertCount(1, $personalWorkspaces); + $this->assertEquals('Second Workspace', $personalWorkspaces->first()->name); + } + + public function test_workspace_list_can_filter_by_active_status(): void + { + // Deactivate second workspace + $this->secondWorkspace->update(['is_active' => false]); + + $this->actingAs($this->owner); + + $activeWorkspaces = $this->owner->workspaces() + ->where('is_active', true) + ->get(); + + $this->assertCount(1, $activeWorkspaces); + $this->assertEquals($this->workspace->id, $activeWorkspaces->first()->id); + + $inactiveWorkspaces = $this->owner->workspaces() + ->where('is_active', false) + ->get(); + + $this->assertCount(1, $inactiveWorkspaces); + $this->assertEquals($this->secondWorkspace->id, $inactiveWorkspaces->first()->id); + } + + public function test_workspace_list_can_search_by_name(): void + { + $this->actingAs($this->owner); + + $results = $this->owner->workspaces() + ->where('workspaces.name', 'like', '%Test%') + ->get(); + + $this->assertCount(1, $results); + $this->assertEquals('Test Workspace', $results->first()->name); + } + + public function test_workspace_list_pagination(): void + { + $this->actingAs($this->owner); + + // Create more workspaces for the owner + for ($i = 0; $i < 5; $i++) { + $ws = Workspace::factory()->create(); + $ws->users()->attach($this->owner->id, [ + 'role' => 'member', + 'is_default' => false, + ]); + } + + // Owner should now have 7 workspaces total (2 original + 5 new) + $total = $this->owner->workspaces()->count(); + $this->assertEquals(7, $total); + + // Paginate with per_page = 3 + $page1 = $this->owner->workspaces() + ->orderBy('workspaces.name', 'asc') + ->paginate(3); + + $this->assertEquals(3, $page1->count()); + $this->assertEquals(7, $page1->total()); + $this->assertEquals(3, $page1->lastPage()); + } + + public function test_workspace_list_per_page_capped_at_100(): void + { + // Verify the controller's per_page capping logic + $requestedPerPage = 200; + $perPage = min($requestedPerPage, 100); + + $this->assertEquals(100, $perPage); + } + + public function test_workspace_list_defaults_ordered_by_default_then_name(): void + { + $this->actingAs($this->owner); + + $workspaces = $this->owner->workspaces() + ->orderBy('user_workspace.is_default', 'desc') + ->orderBy('workspaces.name', 'asc') + ->get(); + + // Default workspace should come first + $first = $workspaces->first(); + $this->assertEquals($this->workspace->id, $first->id); + $this->assertTrue((bool) $first->pivot->is_default); + } + + // ========================================================================= + // SHOW - Get a single workspace + // ========================================================================= + + public function test_user_can_view_workspace_they_belong_to(): void + { + $this->actingAs($this->member); + + $hasAccess = $this->member->workspaces() + ->where('workspaces.id', $this->workspace->id) + ->exists(); + + $this->assertTrue($hasAccess); + } + + public function test_user_cannot_view_workspace_they_dont_belong_to(): void + { + $this->actingAs($this->outsider); + + $hasAccess = $this->outsider->workspaces() + ->where('workspaces.id', $this->workspace->id) + ->exists(); + + $this->assertFalse($hasAccess); + } + + public function test_workspace_show_loads_user_count(): void + { + $workspace = Workspace::withCount('users') + ->find($this->workspace->id); + + // Owner, admin, and member + $this->assertEquals(3, $workspace->users_count); + } + + // ========================================================================= + // STORE - Create a new workspace + // ========================================================================= + + public function test_workspace_can_be_created_with_valid_data(): void + { + $workspace = Workspace::create([ + 'name' => 'New Workspace', + 'slug' => 'new-workspace-abc123', + 'domain' => 'hub.host.uk.com', + 'type' => 'custom', + ]); + + $this->assertDatabaseHas('workspaces', [ + 'id' => $workspace->id, + 'name' => 'New Workspace', + 'slug' => 'new-workspace-abc123', + 'domain' => 'hub.host.uk.com', + 'type' => 'custom', + ]); + } + + public function test_workspace_creator_is_attached_as_owner(): void + { + $user = $this->owner; + + $workspace = Workspace::create([ + 'name' => 'Created Workspace', + 'slug' => 'created-workspace-xyz', + 'domain' => 'hub.host.uk.com', + 'type' => 'custom', + ]); + + // Attach user as owner (as the controller does) + $workspace->users()->attach($user->id, [ + 'role' => 'owner', + 'is_default' => false, + ]); + + $pivot = $workspace->users()->where('user_id', $user->id)->first()->pivot; + + $this->assertEquals('owner', $pivot->role); + $this->assertFalse((bool) $pivot->is_default); + } + + public function test_workspace_slug_must_be_unique(): void + { + $this->expectException(QueryException::class); + + Workspace::create([ + 'name' => 'Duplicate Slug', + 'slug' => 'test-workspace', // Same as $this->workspace + 'domain' => 'hub.host.uk.com', + ]); + } + + public function test_workspace_defaults_type_when_not_provided(): void + { + // The controller sets default type to 'custom' when not provided + $data = [ + 'name' => 'No Type Workspace', + 'slug' => 'no-type-ws', + 'domain' => 'hub.host.uk.com', + ]; + $data['type'] = $data['type'] ?? 'custom'; + + $workspace = Workspace::create($data); + + $this->assertEquals('custom', $workspace->type); + } + + public function test_workspace_store_validation_rules(): void + { + // Verify that the validation rules in the controller are correct + $rules = [ + 'name' => 'required|string|max:255', + 'slug' => 'nullable|string|max:100|unique:workspaces,slug', + 'icon' => 'nullable|string|max:50', + 'color' => 'nullable|string|max:20', + 'description' => 'nullable|string|max:500', + 'type' => 'nullable|string|in:personal,team,agency,custom', + ]; + + // Name is required + $this->assertStringContainsString('required', $rules['name']); + + // Type must be one of the allowed values + $this->assertStringContainsString('in:personal,team,agency,custom', $rules['type']); + + // Slug must be unique + $this->assertStringContainsString('unique:workspaces,slug', $rules['slug']); + } + + public function test_workspace_domain_defaults_to_hub(): void + { + // The controller always sets domain to 'hub.host.uk.com' + $workspace = Workspace::create([ + 'name' => 'Hub Workspace', + 'slug' => 'hub-workspace-test', + 'domain' => 'hub.host.uk.com', + 'type' => 'custom', + ]); + + $this->assertEquals('hub.host.uk.com', $workspace->domain); + } + + // ========================================================================= + // UPDATE - Update a workspace + // ========================================================================= + + public function test_owner_can_update_workspace(): void + { + $this->actingAs($this->owner); + + $pivot = $this->owner->workspaces() + ->where('workspaces.id', $this->workspace->id) + ->first() + ?->pivot; + + $this->assertNotNull($pivot); + $this->assertTrue(in_array($pivot->role, ['owner', 'admin'], true)); + + // Perform the update + $this->workspace->update([ + 'name' => 'Updated Workspace Name', + 'description' => 'Updated description', + ]); + + $this->workspace->refresh(); + $this->assertEquals('Updated Workspace Name', $this->workspace->name); + $this->assertEquals('Updated description', $this->workspace->description); + } + + public function test_admin_can_update_workspace(): void + { + $this->actingAs($this->admin); + + $pivot = $this->admin->workspaces() + ->where('workspaces.id', $this->workspace->id) + ->first() + ?->pivot; + + $this->assertNotNull($pivot); + $this->assertTrue(in_array($pivot->role, ['owner', 'admin'], true)); + + $this->workspace->update(['name' => 'Admin Updated']); + $this->workspace->refresh(); + $this->assertEquals('Admin Updated', $this->workspace->name); + } + + public function test_member_cannot_update_workspace(): void + { + $this->actingAs($this->member); + + $pivot = $this->member->workspaces() + ->where('workspaces.id', $this->workspace->id) + ->first() + ?->pivot; + + $this->assertNotNull($pivot); + // Member role is not owner or admin + $this->assertFalse(in_array($pivot->role, ['owner', 'admin'], true)); + } + + public function test_outsider_cannot_update_workspace(): void + { + $this->actingAs($this->outsider); + + $pivot = $this->outsider->workspaces() + ->where('workspaces.id', $this->workspace->id) + ->first() + ?->pivot; + + // Outsider has no pivot record for this workspace + $this->assertNull($pivot); + } + + public function test_workspace_update_can_change_active_status(): void + { + $this->workspace->update(['is_active' => false]); + $this->workspace->refresh(); + + $this->assertFalse($this->workspace->is_active); + + $this->workspace->update(['is_active' => true]); + $this->workspace->refresh(); + + $this->assertTrue($this->workspace->is_active); + } + + public function test_workspace_slug_uniqueness_on_update(): void + { + $this->expectException(QueryException::class); + + // Try to change workspace slug to the second workspace's slug + $this->workspace->update([ + 'slug' => $this->secondWorkspace->slug, + ]); + } + + // ========================================================================= + // DESTROY - Delete a workspace + // ========================================================================= + + public function test_owner_can_delete_workspace_when_they_have_another(): void + { + $this->actingAs($this->owner); + + // Owner has 2 workspaces, so deletion should be allowed + $workspaceCount = $this->owner->workspaces()->count(); + $this->assertGreaterThan(1, $workspaceCount); + + $pivot = $this->owner->workspaces() + ->where('workspaces.id', $this->secondWorkspace->id) + ->first() + ?->pivot; + + $this->assertEquals('owner', $pivot->role); + + $workspaceId = $this->secondWorkspace->id; + $this->secondWorkspace->delete(); + + // Workspace model does not use SoftDeletes trait, so delete is permanent + $this->assertDatabaseMissing('workspaces', ['id' => $workspaceId]); + } + + public function test_cannot_delete_only_workspace(): void + { + $this->actingAs($this->member); + + // Member has only 1 workspace + $workspaceCount = $this->member->workspaces()->count(); + $this->assertEquals(1, $workspaceCount); + + // Controller would return 422 error in this case + $this->assertTrue($workspaceCount <= 1); + } + + public function test_only_owner_can_delete_workspace(): void + { + $this->actingAs($this->admin); + + $pivot = $this->admin->workspaces() + ->where('workspaces.id', $this->workspace->id) + ->first() + ?->pivot; + + // Admin is not owner, so cannot delete + $this->assertNotEquals('owner', $pivot->role); + } + + public function test_member_cannot_delete_workspace(): void + { + $this->actingAs($this->member); + + $pivot = $this->member->workspaces() + ->where('workspaces.id', $this->workspace->id) + ->first() + ?->pivot; + + $this->assertNotEquals('owner', $pivot->role); + } + + public function test_outsider_cannot_delete_workspace(): void + { + $this->actingAs($this->outsider); + + $pivot = $this->outsider->workspaces() + ->where('workspaces.id', $this->workspace->id) + ->first() + ?->pivot; + + $this->assertNull($pivot); + } + + public function test_workspace_deletion_cascades_user_attachments(): void + { + $this->actingAs($this->owner); + + $workspaceId = $this->secondWorkspace->id; + + // Verify pivot records exist before deletion + $this->assertDatabaseHas('user_workspace', [ + 'workspace_id' => $workspaceId, + 'user_id' => $this->owner->id, + ]); + + $this->secondWorkspace->forceDelete(); + + // Pivot records should be removed via cascade + $this->assertDatabaseMissing('user_workspace', [ + 'workspace_id' => $workspaceId, + ]); + } + + // ========================================================================= + // SWITCH - Switch default workspace + // ========================================================================= + + public function test_user_can_switch_to_a_workspace_they_belong_to(): void + { + $this->actingAs($this->owner); + + // Verify owner has access to second workspace + $hasAccess = $this->owner->workspaces() + ->where('workspaces.id', $this->secondWorkspace->id) + ->exists(); + $this->assertTrue($hasAccess); + + // Simulate the switch transaction (same as controller) + DB::transaction(function () { + // Clear all existing defaults for hub workspaces + DB::table('user_workspace') + ->where('user_id', $this->owner->id) + ->whereIn('workspace_id', function ($query) { + $query->select('id') + ->from('workspaces') + ->where('domain', 'hub.host.uk.com'); + }) + ->update(['is_default' => false]); + + // Set the new default + DB::table('user_workspace') + ->where('user_id', $this->owner->id) + ->where('workspace_id', $this->secondWorkspace->id) + ->update(['is_default' => true]); + }); + + // Verify the switch happened + $defaultWorkspace = $this->owner->workspaces() + ->wherePivot('is_default', true) + ->first(); + + $this->assertNotNull($defaultWorkspace); + $this->assertEquals($this->secondWorkspace->id, $defaultWorkspace->id); + } + + public function test_switch_clears_all_previous_defaults(): void + { + $this->actingAs($this->owner); + + // Initially, workspace is the default + $this->assertDatabaseHas('user_workspace', [ + 'user_id' => $this->owner->id, + 'workspace_id' => $this->workspace->id, + 'is_default' => true, + ]); + + // Perform switch to second workspace + DB::transaction(function () { + DB::table('user_workspace') + ->where('user_id', $this->owner->id) + ->whereIn('workspace_id', function ($query) { + $query->select('id') + ->from('workspaces') + ->where('domain', 'hub.host.uk.com'); + }) + ->update(['is_default' => false]); + + DB::table('user_workspace') + ->where('user_id', $this->owner->id) + ->where('workspace_id', $this->secondWorkspace->id) + ->update(['is_default' => true]); + }); + + // The old default should now be false + $this->assertDatabaseHas('user_workspace', [ + 'user_id' => $this->owner->id, + 'workspace_id' => $this->workspace->id, + 'is_default' => false, + ]); + + // The new workspace should be the default + $this->assertDatabaseHas('user_workspace', [ + 'user_id' => $this->owner->id, + 'workspace_id' => $this->secondWorkspace->id, + 'is_default' => true, + ]); + } + + public function test_user_cannot_switch_to_workspace_they_dont_belong_to(): void + { + $this->actingAs($this->outsider); + + $hasAccess = $this->outsider->workspaces() + ->where('workspaces.id', $this->workspace->id) + ->exists(); + + $this->assertFalse($hasAccess); + } + + public function test_switch_only_affects_hub_domain_workspaces(): void + { + $this->actingAs($this->owner); + + // Create a non-hub workspace + $externalWorkspace = Workspace::factory()->create([ + 'domain' => 'other-domain.example.com', + ]); + $externalWorkspace->users()->attach($this->owner->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + + // Perform switch to second hub workspace + DB::transaction(function () { + DB::table('user_workspace') + ->where('user_id', $this->owner->id) + ->whereIn('workspace_id', function ($query) { + $query->select('id') + ->from('workspaces') + ->where('domain', 'hub.host.uk.com'); + }) + ->update(['is_default' => false]); + + DB::table('user_workspace') + ->where('user_id', $this->owner->id) + ->where('workspace_id', $this->secondWorkspace->id) + ->update(['is_default' => true]); + }); + + // External workspace default should NOT have changed + $externalPivot = DB::table('user_workspace') + ->where('user_id', $this->owner->id) + ->where('workspace_id', $externalWorkspace->id) + ->first(); + + $this->assertTrue((bool) $externalPivot->is_default); + } + + public function test_switch_is_atomic_within_transaction(): void + { + $this->actingAs($this->owner); + + // Perform switch + DB::transaction(function () { + DB::table('user_workspace') + ->where('user_id', $this->owner->id) + ->whereIn('workspace_id', function ($query) { + $query->select('id') + ->from('workspaces') + ->where('domain', 'hub.host.uk.com'); + }) + ->update(['is_default' => false]); + + DB::table('user_workspace') + ->where('user_id', $this->owner->id) + ->where('workspace_id', $this->secondWorkspace->id) + ->update(['is_default' => true]); + }); + + // After switch, only one hub workspace should be default + $hubDefaults = DB::table('user_workspace') + ->where('user_id', $this->owner->id) + ->where('is_default', true) + ->whereIn('workspace_id', function ($query) { + $query->select('id') + ->from('workspaces') + ->where('domain', 'hub.host.uk.com'); + }) + ->count(); + + $this->assertEquals(1, $hubDefaults); + } + + // ========================================================================= + // CURRENT - Get current workspace + // ========================================================================= + + public function test_default_host_workspace_resolves_for_authenticated_user(): void + { + $this->actingAs($this->owner); + + $default = $this->owner->defaultHostWorkspace(); + + $this->assertNotNull($default); + $this->assertEquals($this->workspace->id, $default->id); + } + + public function test_default_host_workspace_falls_back_to_first_workspace(): void + { + // Remove all defaults + DB::table('user_workspace') + ->where('user_id', $this->member->id) + ->update(['is_default' => false]); + + $this->actingAs($this->member); + + $default = $this->member->defaultHostWorkspace(); + + // Should fall back to the first workspace + $this->assertNotNull($default); + $this->assertEquals($this->workspace->id, $default->id); + } + + public function test_user_with_no_workspaces_returns_null(): void + { + $loneUser = User::factory()->create(['name' => 'Lone User']); + + $this->actingAs($loneUser); + + $default = $loneUser->defaultHostWorkspace(); + + $this->assertNull($default); + } + + // ========================================================================= + // ACCESS CONTROL - Role-based permission checks + // ========================================================================= + + public function test_workspace_role_is_correctly_set_on_pivot(): void + { + $ownerPivot = $this->workspace->users() + ->where('user_id', $this->owner->id) + ->first() + ->pivot; + $this->assertEquals('owner', $ownerPivot->role); + + $adminPivot = $this->workspace->users() + ->where('user_id', $this->admin->id) + ->first() + ->pivot; + $this->assertEquals('admin', $adminPivot->role); + + $memberPivot = $this->workspace->users() + ->where('user_id', $this->member->id) + ->first() + ->pivot; + $this->assertEquals('member', $memberPivot->role); + } + + public function test_workspace_owner_method_returns_correct_user(): void + { + $owner = $this->workspace->owner(); + + $this->assertNotNull($owner); + $this->assertEquals($this->owner->id, $owner->id); + } + + public function test_workspace_users_count(): void + { + $count = $this->workspace->users()->count(); + + // Owner, admin, member + $this->assertEquals(3, $count); + } + + // ========================================================================= + // EDGE CASES + // ========================================================================= + + public function test_workspace_with_special_characters_in_name(): void + { + $workspace = Workspace::create([ + 'name' => "O'Brien's & Co. (Test)", + 'slug' => 'obriens-co-test', + 'domain' => 'hub.host.uk.com', + 'type' => 'custom', + ]); + + $this->assertEquals("O'Brien's & Co. (Test)", $workspace->name); + } + + public function test_workspace_active_scope(): void + { + $this->secondWorkspace->update(['is_active' => false]); + + $activeCount = Workspace::active()->count(); + // secondWorkspace is inactive now, so active count should be less than total + $allCount = Workspace::count(); + + $this->assertLessThan($allCount, $activeCount); + } + + public function test_workspace_is_active_cast_to_boolean(): void + { + $workspace = Workspace::factory()->create(['is_active' => true]); + + $this->assertIsBool($workspace->is_active); + $this->assertTrue($workspace->is_active); + + $workspace->update(['is_active' => false]); + $workspace->refresh(); + + $this->assertIsBool($workspace->is_active); + $this->assertFalse($workspace->is_active); + } + + public function test_workspace_settings_cast_to_array(): void + { + $workspace = Workspace::factory()->create([ + 'settings' => ['theme' => 'dark', 'language' => 'en'], + ]); + + $this->assertIsArray($workspace->settings); + $this->assertEquals('dark', $workspace->settings['theme']); + $this->assertEquals('en', $workspace->settings['language']); + } + + public function test_get_setting_returns_value_or_default(): void + { + $workspace = Workspace::factory()->create([ + 'settings' => ['theme' => 'dark'], + ]); + + $this->assertEquals('dark', $workspace->getSetting('theme')); + $this->assertNull($workspace->getSetting('nonexistent')); + $this->assertEquals('fallback', $workspace->getSetting('nonexistent', 'fallback')); + } + + public function test_workspace_to_service_array(): void + { + $array = $this->workspace->toServiceArray(); + + $this->assertArrayHasKey('name', $array); + $this->assertArrayHasKey('slug', $array); + $this->assertArrayHasKey('domain', $array); + $this->assertArrayHasKey('icon', $array); + $this->assertArrayHasKey('color', $array); + $this->assertArrayHasKey('description', $array); + $this->assertEquals('Test Workspace', $array['name']); + $this->assertEquals('test-workspace', $array['slug']); + } +}