diff --git a/Models/Feature.php b/Models/Feature.php index de37b99..f31ceb1 100644 --- a/Models/Feature.php +++ b/Models/Feature.php @@ -10,6 +10,35 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +/** + * Feature model - entitlement feature definition (e.g. social.accounts, bio.pages). + * + * @property int $id + * @property string $code + * @property string $name + * @property string|null $description + * @property string|null $category + * @property string $type + * @property string $reset_type + * @property int|null $rolling_window_days + * @property int|null $parent_feature_id + * @property int $sort_order + * @property bool $is_active + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \Illuminate\Database\Eloquent\Collection $packages + * @property-read Feature|null $parent + * @property-read \Illuminate\Database\Eloquent\Collection $children + * + * @method static \Illuminate\Database\Eloquent\Builder|Feature active() + * @method static \Illuminate\Database\Eloquent\Builder|Feature inCategory(string $category) + * @method static \Illuminate\Database\Eloquent\Builder|Feature root() + * @method static \Illuminate\Database\Eloquent\Builder|Feature newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Feature newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Feature query() + * + * @mixin \Eloquent + */ class Feature extends Model { use HasFactory; diff --git a/Models/Namespace_.php b/Models/Namespace_.php index b077de7..31a2664 100644 --- a/Models/Namespace_.php +++ b/Models/Namespace_.php @@ -19,10 +19,40 @@ use Illuminate\Support\Str; * a namespace rather than directly to User/Workspace. The namespace itself * has polymorphic ownership (User or Workspace can own). * - * Ownership patterns: - * - Individual user: User → Namespace → Products - * - Agency: Workspace → Namespace(s) → Products (one per client) - * - Team member: User in Workspace → access to Workspace's Namespaces + * @property int $id + * @property string $uuid + * @property string $name + * @property string $slug + * @property string|null $description + * @property string $icon + * @property string $color + * @property string $owner_type + * @property int $owner_id + * @property int|null $workspace_id + * @property array|null $settings + * @property bool $is_default + * @property bool $is_active + * @property int $sort_order + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property \Illuminate\Support\Carbon|null $deleted_at + * @property-read User|Workspace $owner + * @property-read Workspace|null $workspace + * @property-read \Illuminate\Database\Eloquent\Collection $namespacePackages + * @property-read \Illuminate\Database\Eloquent\Collection $boosts + * @property-read \Illuminate\Database\Eloquent\Collection $usageRecords + * @property-read \Illuminate\Database\Eloquent\Collection $entitlementLogs + * + * @method static \Illuminate\Database\Eloquent\Builder|Namespace_ active() + * @method static \Illuminate\Database\Eloquent\Builder|Namespace_ ordered() + * @method static \Illuminate\Database\Eloquent\Builder|Namespace_ ownedByUser(User|int $user) + * @method static \Illuminate\Database\Eloquent\Builder|Namespace_ ownedByWorkspace(Workspace|int $workspace) + * @method static \Illuminate\Database\Eloquent\Builder|Namespace_ accessibleBy(User $user) + * @method static \Illuminate\Database\Eloquent\Builder|Namespace_ newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Namespace_ newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Namespace_ query() + * + * @mixin \Eloquent */ class Namespace_ extends Model { diff --git a/Models/Package.php b/Models/Package.php index 96bbc19..c2b8fa9 100644 --- a/Models/Package.php +++ b/Models/Package.php @@ -9,6 +9,47 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +/** + * Package model - entitlement package definition (e.g. Free, Creator, Agency). + * + * @property int $id + * @property string $code + * @property string $name + * @property string|null $description + * @property string|null $icon + * @property string|null $color + * @property int $sort_order + * @property bool $is_stackable + * @property bool $is_base_package + * @property bool $is_active + * @property bool $is_public + * @property string|null $blesta_package_id + * @property float|null $monthly_price + * @property float|null $yearly_price + * @property float $setup_fee + * @property int $trial_days + * @property string|null $stripe_price_id_monthly + * @property string|null $stripe_price_id_yearly + * @property string|null $btcpay_price_id_monthly + * @property string|null $btcpay_price_id_yearly + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \Illuminate\Database\Eloquent\Collection $features + * @property-read \Illuminate\Database\Eloquent\Collection $workspacePackages + * + * @method static \Illuminate\Database\Eloquent\Builder|Package active() + * @method static \Illuminate\Database\Eloquent\Builder|Package public() + * @method static \Illuminate\Database\Eloquent\Builder|Package base() + * @method static \Illuminate\Database\Eloquent\Builder|Package addons() + * @method static \Illuminate\Database\Eloquent\Builder|Package purchasable() + * @method static \Illuminate\Database\Eloquent\Builder|Package free() + * @method static \Illuminate\Database\Eloquent\Builder|Package ordered() + * @method static \Illuminate\Database\Eloquent\Builder|Package newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Package newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Package query() + * + * @mixin \Eloquent + */ class Package extends Model { use HasFactory; diff --git a/Models/User.php b/Models/User.php index 85af40f..8561557 100644 --- a/Models/User.php +++ b/Models/User.php @@ -18,6 +18,39 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Pennant\Concerns\HasFeatures; +/** + * User model - authenticatable tenant user. + * + * @property int $id + * @property string $name + * @property string $email + * @property \Illuminate\Support\Carbon|null $email_verified_at + * @property string $password + * @property string|null $remember_token + * @property \Core\Tenant\Enums\UserTier $tier + * @property \Illuminate\Support\Carbon|null $tier_expires_at + * @property int|null $referred_by + * @property int $referral_count + * @property \Illuminate\Support\Carbon|null $referral_activated_at + * @property array|null $cached_stats + * @property \Illuminate\Support\Carbon|null $stats_computed_at + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \Illuminate\Database\Eloquent\Collection $workspaces + * @property-read \Illuminate\Database\Eloquent\Collection $ownedWorkspaces + * @property-read \Illuminate\Database\Eloquent\Collection $namespaces + * @property-read \Illuminate\Database\Eloquent\Collection $boosts + * @property-read User|null $referrer + * @property-read \Illuminate\Database\Eloquent\Collection $referrals + * @property-read \Illuminate\Notifications\DatabaseNotificationCollection $notifications + * + * @method static \Illuminate\Database\Eloquent\Builder|User newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|User newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|User query() + * @method static \Core\Tenant\Database\Factories\UserFactory factory($count = null, $state = []) + * + * @mixin \Eloquent + */ class User extends Authenticatable implements MustVerifyEmail { use HasFactory, HasFeatures, Notifiable; diff --git a/Models/Workspace.php b/Models/Workspace.php index 490bc64..c81c6ac 100644 --- a/Models/Workspace.php +++ b/Models/Workspace.php @@ -50,6 +50,65 @@ use Mod\Commerce\Models\Order; use Mod\Commerce\Models\PaymentMethod; use Mod\Commerce\Models\Subscription; +/** + * Workspace model - the core tenant boundary. + * + * @property int $id + * @property string $name + * @property string $slug + * @property string|null $domain + * @property string|null $icon + * @property string|null $color + * @property string|null $description + * @property string $type + * @property array|null $settings + * @property bool $is_active + * @property int $sort_order + * @property bool $wp_connector_enabled + * @property string|null $wp_connector_url + * @property string|null $wp_connector_secret + * @property \Illuminate\Support\Carbon|null $wp_connector_verified_at + * @property \Illuminate\Support\Carbon|null $wp_connector_last_sync + * @property array|null $wp_connector_config + * @property string|null $stripe_customer_id + * @property string|null $btcpay_customer_id + * @property string|null $billing_name + * @property string|null $billing_email + * @property string|null $billing_address_line1 + * @property string|null $billing_address_line2 + * @property string|null $billing_city + * @property string|null $billing_state + * @property string|null $billing_postal_code + * @property string|null $billing_country + * @property string|null $vat_number + * @property string|null $tax_id + * @property bool $tax_exempt + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read string $cms_url + * @property-read string $wp_connector_webhook_url + * @property-read \Illuminate\Database\Eloquent\Collection $users + * @property-read \Illuminate\Database\Eloquent\Collection $members + * @property-read \Illuminate\Database\Eloquent\Collection $teams + * @property-read \Illuminate\Database\Eloquent\Collection $workspacePackages + * @property-read \Illuminate\Database\Eloquent\Collection $invitations + * @property-read \Illuminate\Database\Eloquent\Collection $namespaces + * @property-read \Illuminate\Database\Eloquent\Collection $packages + * @property-read \Illuminate\Database\Eloquent\Collection $boosts + * @property-read \Illuminate\Database\Eloquent\Collection $usageRecords + * @property-read \Illuminate\Database\Eloquent\Collection $entitlementLogs + * @property-read \Illuminate\Database\Eloquent\Collection $usageAlerts + * @property-read \Illuminate\Database\Eloquent\Collection $entitlementWebhooks + * + * @method static \Illuminate\Database\Eloquent\Builder|Workspace active() + * @method static \Illuminate\Database\Eloquent\Builder|Workspace ordered() + * @method static \Illuminate\Database\Eloquent\Builder|Workspace newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Workspace newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Workspace query() + * @method static \Core\Tenant\Database\Factories\WorkspaceFactory factory($count = null, $state = []) + * + * @mixin \Eloquent + */ class Workspace extends Model { use HasFactory; diff --git a/Models/WorkspaceInvitation.php b/Models/WorkspaceInvitation.php index a832d10..ebacee3 100644 --- a/Models/WorkspaceInvitation.php +++ b/Models/WorkspaceInvitation.php @@ -12,6 +12,33 @@ use Illuminate\Notifications\Notifiable; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; +/** + * Workspace Invitation - manages invitations to join workspaces. + * + * @property int $id + * @property int $workspace_id + * @property string $email + * @property string $token + * @property string $role + * @property int|null $invited_by + * @property \Illuminate\Support\Carbon $expires_at + * @property \Illuminate\Support\Carbon|null $accepted_at + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read Workspace $workspace + * @property-read User|null $inviter + * @property-read \Illuminate\Notifications\DatabaseNotificationCollection $notifications + * + * @method static \Illuminate\Database\Eloquent\Builder|WorkspaceInvitation pending() + * @method static \Illuminate\Database\Eloquent\Builder|WorkspaceInvitation expired() + * @method static \Illuminate\Database\Eloquent\Builder|WorkspaceInvitation accepted() + * @method static \Illuminate\Database\Eloquent\Builder|WorkspaceInvitation newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|WorkspaceInvitation newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|WorkspaceInvitation query() + * @method static \Core\Tenant\Database\Factories\WorkspaceInvitationFactory factory($count = null, $state = []) + * + * @mixin \Eloquent + */ class WorkspaceInvitation extends Model { use HasFactory; diff --git a/Models/WorkspaceMember.php b/Models/WorkspaceMember.php index 1d04a9c..2eb89ce 100644 --- a/Models/WorkspaceMember.php +++ b/Models/WorkspaceMember.php @@ -25,6 +25,21 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property int|null $invited_by * @property Carbon $created_at * @property Carbon $updated_at + * @property-read User $user + * @property-read Workspace $workspace + * @property-read WorkspaceTeam|null $team + * @property-read User|null $inviter + * + * @method static \Illuminate\Database\Eloquent\Builder|WorkspaceMember forWorkspace(Workspace|int $workspace) + * @method static \Illuminate\Database\Eloquent\Builder|WorkspaceMember forUser(User|int $user) + * @method static \Illuminate\Database\Eloquent\Builder|WorkspaceMember withRole(string $role) + * @method static \Illuminate\Database\Eloquent\Builder|WorkspaceMember inTeam(WorkspaceTeam|int $team) + * @method static \Illuminate\Database\Eloquent\Builder|WorkspaceMember owners() + * @method static \Illuminate\Database\Eloquent\Builder|WorkspaceMember newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|WorkspaceMember newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|WorkspaceMember query() + * + * @mixin \Eloquent */ class WorkspaceMember extends Model { 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']); + } +}