diff --git a/tests/Feature/NamespaceServiceTest.php b/tests/Feature/NamespaceServiceTest.php new file mode 100644 index 0000000..409df36 --- /dev/null +++ b/tests/Feature/NamespaceServiceTest.php @@ -0,0 +1,457 @@ +service = app(NamespaceService::class); + + // Create users + $this->user = User::factory()->create(['name' => 'Test User']); + $this->otherUser = User::factory()->create(['name' => 'Other User']); + + // Create workspaces + $this->workspace = Workspace::factory()->create(['name' => 'Test Workspace']); + $this->otherWorkspace = Workspace::factory()->create(['name' => 'Other Workspace']); + + // Attach users to workspaces + $this->user->hostWorkspaces()->attach($this->workspace, [ + 'role' => 'owner', + 'is_default' => true, + ]); + $this->otherUser->hostWorkspaces()->attach($this->otherWorkspace, [ + 'role' => 'owner', + 'is_default' => true, + ]); + + // Create user-owned namespace + $this->userNamespace = Namespace_::create([ + 'name' => 'Personal', + 'slug' => 'personal', + 'owner_type' => User::class, + 'owner_id' => $this->user->id, + 'is_default' => true, + 'is_active' => true, + 'sort_order' => 0, + ]); + + // Create workspace-owned namespace + $this->workspaceNamespace = Namespace_::create([ + 'name' => 'Agency Client', + 'slug' => 'agency-client', + 'owner_type' => Workspace::class, + 'owner_id' => $this->workspace->id, + 'workspace_id' => $this->workspace->id, + 'is_active' => true, + 'sort_order' => 0, + ]); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// current() resolution +// ───────────────────────────────────────────────────────────────────────────── + +describe('current namespace resolution', function () { + + it('returns namespace from request attributes when set', function () { + request()->attributes->set('current_namespace', $this->userNamespace); + + $this->actingAs($this->user); + + $current = $this->service->current(); + + expect($current)->not->toBeNull(); + expect($current->id)->toBe($this->userNamespace->id); + }); + + it('returns namespace from session UUID when user has access', function () { + $this->actingAs($this->user); + + session(['current_namespace_uuid' => $this->userNamespace->uuid]); + + $current = $this->service->current(); + + expect($current)->not->toBeNull(); + expect($current->id)->toBe($this->userNamespace->id); + }); + + it('falls back to default namespace when session UUID is missing', function () { + $this->actingAs($this->user); + + $current = $this->service->current(); + + expect($current)->not->toBeNull(); + expect($current->id)->toBe($this->userNamespace->id); + }); + + it('rejects session namespace when user lacks access', function () { + $this->actingAs($this->otherUser); + + // Other user tries to access this user's namespace via session + session(['current_namespace_uuid' => $this->userNamespace->uuid]); + + $current = $this->service->current(); + + // Should not return the inaccessible namespace + if ($current !== null) { + expect($current->id)->not->toBe($this->userNamespace->id); + } else { + expect($current)->toBeNull(); + } + }); + + it('returns null when no user is authenticated', function () { + $current = $this->service->current(); + + expect($current)->toBeNull(); + }); + +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Session management +// ───────────────────────────────────────────────────────────────────────────── + +describe('session management', function () { + + it('sets current namespace UUID in session from model', function () { + $this->service->setCurrent($this->userNamespace); + + expect(session('current_namespace_uuid'))->toBe($this->userNamespace->uuid); + }); + + it('sets current namespace UUID in session from string', function () { + $uuid = $this->userNamespace->uuid; + + $this->service->setCurrent($uuid); + + expect(session('current_namespace_uuid'))->toBe($uuid); + }); + + it('returns current UUID from session', function () { + session(['current_namespace_uuid' => $this->userNamespace->uuid]); + + expect($this->service->currentUuid())->toBe($this->userNamespace->uuid); + }); + + it('returns null UUID when session is empty', function () { + expect($this->service->currentUuid())->toBeNull(); + }); + + it('clears current namespace from session', function () { + session(['current_namespace_uuid' => $this->userNamespace->uuid]); + + $this->service->clearCurrent(); + + expect(session('current_namespace_uuid'))->toBeNull(); + }); + +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Lookup methods (findByUuid, findBySlug) +// ───────────────────────────────────────────────────────────────────────────── + +describe('namespace lookup', function () { + + it('finds namespace by UUID', function () { + $found = $this->service->findByUuid($this->userNamespace->uuid); + + expect($found)->not->toBeNull(); + expect($found->id)->toBe($this->userNamespace->id); + }); + + it('returns null for unknown UUID', function () { + $found = $this->service->findByUuid('non-existent-uuid'); + + expect($found)->toBeNull(); + }); + + it('caches namespace by UUID', function () { + // First call populates cache + $this->service->findByUuid($this->userNamespace->uuid); + + // Verify cache key exists + expect(Cache::has("namespace:uuid:{$this->userNamespace->uuid}"))->toBeTrue(); + }); + + it('finds namespace by slug within user context', function () { + $found = $this->service->findBySlug('personal', $this->user); + + expect($found)->not->toBeNull(); + expect($found->id)->toBe($this->userNamespace->id); + }); + + it('finds namespace by slug within workspace context', function () { + $found = $this->service->findBySlug('agency-client', $this->workspace); + + expect($found)->not->toBeNull(); + expect($found->id)->toBe($this->workspaceNamespace->id); + }); + + it('returns null for slug in wrong owner context', function () { + // Looking for a user namespace slug against the workspace owner + $found = $this->service->findBySlug('personal', $this->workspace); + + expect($found)->toBeNull(); + }); + +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Default namespace resolution +// ───────────────────────────────────────────────────────────────────────────── + +describe('default namespace for user', function () { + + it('returns the default namespace marked is_default', function () { + $default = $this->service->defaultForUser($this->user); + + expect($default)->not->toBeNull(); + expect($default->id)->toBe($this->userNamespace->id); + expect($default->is_default)->toBeTrue(); + }); + + it('falls back to first active user-owned namespace when no explicit default', function () { + // Remove the is_default flag + $this->userNamespace->update(['is_default' => false]); + + $default = $this->service->defaultForUser($this->user); + + expect($default)->not->toBeNull(); + expect($default->id)->toBe($this->userNamespace->id); + }); + + it('falls back to workspace namespace when user has no owned namespaces', function () { + // Delete the user-owned namespace + $this->userNamespace->forceDelete(); + + $default = $this->service->defaultForUser($this->user); + + expect($default)->not->toBeNull(); + expect($default->id)->toBe($this->workspaceNamespace->id); + }); + + it('returns null when user has no accessible namespaces', function () { + $lonelyUser = User::factory()->create(['name' => 'Lonely User']); + + $default = $this->service->defaultForUser($lonelyUser); + + expect($default)->toBeNull(); + }); + + it('skips inactive namespaces when resolving default', function () { + $this->userNamespace->update(['is_active' => false]); + + $default = $this->service->defaultForUser($this->user); + + // Should skip the inactive personal namespace and fall back to workspace ns + expect($default)->not->toBeNull(); + expect($default->id)->toBe($this->workspaceNamespace->id); + }); + + it('returns null for defaultForCurrentUser when unauthenticated', function () { + $default = $this->service->defaultForCurrentUser(); + + expect($default)->toBeNull(); + }); + +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Access control +// ───────────────────────────────────────────────────────────────────────────── + +describe('access control', function () { + + it('grants access to user-owned namespace', function () { + $this->actingAs($this->user); + + expect($this->service->canAccess($this->userNamespace))->toBeTrue(); + }); + + it('grants access to namespace owned by user workspace', function () { + $this->actingAs($this->user); + + expect($this->service->canAccess($this->workspaceNamespace))->toBeTrue(); + }); + + it('denies access to namespace owned by another user', function () { + $this->actingAs($this->otherUser); + + expect($this->service->canAccess($this->userNamespace))->toBeFalse(); + }); + + it('denies access to namespace from workspace user is not member of', function () { + $this->actingAs($this->otherUser); + + expect($this->service->canAccess($this->workspaceNamespace))->toBeFalse(); + }); + + it('denies access when no user is authenticated', function () { + expect($this->service->canAccess($this->userNamespace))->toBeFalse(); + }); + + it('grants access to workspace member for workspace namespace', function () { + // Add otherUser as a member of the first workspace + $this->otherUser->hostWorkspaces()->attach($this->workspace, [ + 'role' => 'member', + 'is_default' => false, + ]); + + $this->actingAs($this->otherUser); + + expect($this->service->canAccess($this->workspaceNamespace))->toBeTrue(); + }); + +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Collection methods (ownedBy, accessibleBy) +// ───────────────────────────────────────────────────────────────────────────── + +describe('namespace collections', function () { + + it('returns namespaces owned by user', function () { + $namespaces = $this->service->ownedByUser($this->user); + + expect($namespaces)->toHaveCount(1); + expect($namespaces->first()->id)->toBe($this->userNamespace->id); + }); + + it('returns namespaces owned by workspace', function () { + $namespaces = $this->service->ownedByWorkspace($this->workspace); + + expect($namespaces)->toHaveCount(1); + expect($namespaces->first()->id)->toBe($this->workspaceNamespace->id); + }); + + it('returns all accessible namespaces for user', function () { + $namespaces = $this->service->accessibleByUser($this->user); + + // User owns one namespace and is member of one workspace with a namespace + expect($namespaces)->toHaveCount(2); + }); + + it('excludes inactive namespaces from accessible list', function () { + $this->workspaceNamespace->update(['is_active' => false]); + Cache::flush(); // Clear cached accessible namespaces + + $namespaces = $this->service->accessibleByUser($this->user); + + expect($namespaces)->toHaveCount(1); + expect($namespaces->first()->id)->toBe($this->userNamespace->id); + }); + + it('returns empty collection for accessibleByCurrentUser when unauthenticated', function () { + $namespaces = $this->service->accessibleByCurrentUser(); + + expect($namespaces)->toBeEmpty(); + }); + + it('caches accessible namespaces for user', function () { + $this->service->accessibleByUser($this->user); + + expect(Cache::has("user:{$this->user->id}:accessible_namespaces"))->toBeTrue(); + }); + +}); + +// ───────────────────────────────────────────────────────────────────────────── +// groupedForUser +// ───────────────────────────────────────────────────────────────────────────── + +describe('grouped namespaces for user', function () { + + it('separates personal and workspace namespaces', function () { + $grouped = $this->service->groupedForUser($this->user); + + expect($grouped)->toHaveKeys(['personal', 'workspaces']); + expect($grouped['personal'])->toHaveCount(1); + expect($grouped['personal']->first()->id)->toBe($this->userNamespace->id); + + expect($grouped['workspaces'])->toHaveCount(1); + expect($grouped['workspaces'][0]['workspace']->id)->toBe($this->workspace->id); + expect($grouped['workspaces'][0]['namespaces'])->toHaveCount(1); + expect($grouped['workspaces'][0]['namespaces']->first()->id)->toBe($this->workspaceNamespace->id); + }); + + it('excludes workspaces with no active namespaces', function () { + $this->workspaceNamespace->update(['is_active' => false]); + + $grouped = $this->service->groupedForUser($this->user); + + expect($grouped['workspaces'])->toBeEmpty(); + }); + + it('returns empty structure for unauthenticated user via groupedForCurrentUser', function () { + $grouped = $this->service->groupedForCurrentUser(); + + expect($grouped['personal'])->toBeEmpty(); + expect($grouped['workspaces'])->toBeEmpty(); + }); + + it('includes multiple workspaces when user is member of several', function () { + // Add user to other workspace + $this->user->hostWorkspaces()->attach($this->otherWorkspace, [ + 'role' => 'member', + 'is_default' => false, + ]); + + // Create a namespace in the other workspace + Namespace_::create([ + 'name' => 'Other Client', + 'slug' => 'other-client', + 'owner_type' => Workspace::class, + 'owner_id' => $this->otherWorkspace->id, + 'workspace_id' => $this->otherWorkspace->id, + 'is_active' => true, + 'sort_order' => 0, + ]); + + $grouped = $this->service->groupedForUser($this->user); + + expect($grouped['workspaces'])->toHaveCount(2); + }); + +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Cache invalidation +// ───────────────────────────────────────────────────────────────────────────── + +describe('cache invalidation', function () { + + it('invalidates user accessible namespaces cache', function () { + // Populate cache + $this->service->accessibleByUser($this->user); + expect(Cache::has("user:{$this->user->id}:accessible_namespaces"))->toBeTrue(); + + // Invalidate + $this->service->invalidateUserCache($this->user); + + expect(Cache::has("user:{$this->user->id}:accessible_namespaces"))->toBeFalse(); + }); + + it('invalidates namespace UUID cache', function () { + // Populate cache + $this->service->findByUuid($this->userNamespace->uuid); + expect(Cache::has("namespace:uuid:{$this->userNamespace->uuid}"))->toBeTrue(); + + // Invalidate + $this->service->invalidateCache($this->userNamespace->uuid); + + expect(Cache::has("namespace:uuid:{$this->userNamespace->uuid}"))->toBeFalse(); + }); + +});