php-tenant/tests/Feature/NamespaceServiceTest.php
Claude dff55fef13
test: add comprehensive tests for NamespaceService
Cover current() resolution (request attributes, session, fallback),
session management (set/get/clear), findByUuid/findBySlug lookups,
defaultForUser priority chain, access control (owner/member/deny),
collection methods (ownedBy, accessibleBy), groupedForUser grouping,
and cache invalidation. 40 test cases using Pest syntax.

Fixes #30

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

457 lines
18 KiB
PHP

<?php
declare(strict_types=1);
use Core\Tenant\Models\Namespace_;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Services\NamespaceService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
uses(RefreshDatabase::class);
beforeEach(function () {
Cache::flush();
$this->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();
});
});