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>
457 lines
18 KiB
PHP
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();
|
|
});
|
|
|
|
});
|