php-tenant/tests/Feature/WorkspaceInvitationTest.php

418 lines
14 KiB
PHP
Raw Normal View History

2026-01-26 21:08:59 +00:00
<?php
declare(strict_types=1);
namespace Core\Tenant\Tests\Feature;
2026-01-26 21:08:59 +00:00
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Models\WorkspaceInvitation;
use Core\Tenant\Notifications\WorkspaceInvitationNotification;
2026-01-26 21:08:59 +00:00
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
2026-01-26 21:08:59 +00:00
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class WorkspaceInvitationTest extends TestCase
{
use RefreshDatabase;
public function test_workspace_can_invite_user_by_email(): void
{
Notification::fake();
$owner = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($owner->id, ['role' => 'owner']);
$invitation = $workspace->invite('newuser@example.com', 'member', $owner);
$this->assertDatabaseHas('workspace_invitations', [
'workspace_id' => $workspace->id,
'email' => 'newuser@example.com',
'role' => 'member',
'invited_by' => $owner->id,
]);
// Token should be hashed (starts with $2y$)
2026-01-26 21:08:59 +00:00
$this->assertNotNull($invitation->token);
$this->assertTrue(str_starts_with($invitation->token, '$2y$'));
2026-01-26 21:08:59 +00:00
$this->assertTrue($invitation->isPending());
$this->assertFalse($invitation->isExpired());
$this->assertFalse($invitation->isAccepted());
Notification::assertSentTo($invitation, WorkspaceInvitationNotification::class);
}
public function test_invitation_token_is_hashed(): void
{
Notification::fake();
$workspace = Workspace::factory()->create();
$invitation = $workspace->invite('test@example.com', 'member');
// Token should be hashed (bcrypt format)
$this->assertTrue(str_starts_with($invitation->token, '$2y$'));
$this->assertEquals(60, strlen($invitation->token));
}
2026-01-26 21:08:59 +00:00
public function test_invitation_expires_after_set_days(): void
{
$workspace = Workspace::factory()->create();
$invitation = $workspace->invite('test@example.com', 'member', null, 3);
$this->assertTrue($invitation->expires_at->isBetween(
now()->addDays(2)->addHours(23),
now()->addDays(3)->addHours(1)
));
}
public function test_user_can_accept_invitation(): void
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create(['email' => 'invited@example.com']);
$invitation = WorkspaceInvitation::factory()->create([
'workspace_id' => $workspace->id,
'email' => 'invited@example.com',
'role' => 'admin',
]);
$result = $invitation->accept($user);
$this->assertTrue($result);
$this->assertTrue($invitation->fresh()->isAccepted());
$this->assertTrue($workspace->users()->where('user_id', $user->id)->exists());
$this->assertEquals('admin', $workspace->users()->find($user->id)->pivot->role);
}
public function test_expired_invitation_cannot_be_accepted(): void
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
$invitation = WorkspaceInvitation::factory()->expired()->create([
'workspace_id' => $workspace->id,
]);
$result = $invitation->accept($user);
$this->assertFalse($result);
$this->assertFalse($workspace->users()->where('user_id', $user->id)->exists());
}
public function test_already_accepted_invitation_cannot_be_reused(): void
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
$invitation = WorkspaceInvitation::factory()->accepted()->create([
'workspace_id' => $workspace->id,
]);
$result = $invitation->accept($user);
$this->assertFalse($result);
}
public function test_resending_invitation_updates_existing(): void
{
Notification::fake();
$workspace = Workspace::factory()->create();
$owner = User::factory()->create();
// First invitation as member
$first = $workspace->invite('test@example.com', 'member', $owner);
$firstToken = $first->token;
// Second invitation as admin - should update existing
$second = $workspace->invite('test@example.com', 'admin', $owner);
$this->assertEquals($first->id, $second->id);
// Token should change when re-inviting (new token generated and hashed)
$this->assertNotEquals($firstToken, $second->fresh()->token);
2026-01-26 21:08:59 +00:00
$this->assertEquals('admin', $second->role);
// Should only have one invitation
$this->assertEquals(1, $workspace->invitations()->count());
}
public function test_static_accept_invitation_method(): void
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
// Use a known plaintext token
$plaintextToken = 'test-plaintext-token-for-acceptance-testing-1234567890';
2026-01-26 21:08:59 +00:00
$invitation = WorkspaceInvitation::factory()
->withPlaintextToken($plaintextToken)
->create([
'workspace_id' => $workspace->id,
'role' => 'member',
]);
// Accept using the plaintext token (model stores hashed version)
$result = Workspace::acceptInvitation($plaintextToken, $user);
2026-01-26 21:08:59 +00:00
$this->assertTrue($result);
$this->assertTrue($workspace->users()->where('user_id', $user->id)->exists());
}
public function test_find_by_token_uses_hash_check(): void
{
$workspace = Workspace::factory()->create();
$plaintextToken = 'my-secret-plaintext-token-for-testing-hash-lookup';
$invitation = WorkspaceInvitation::factory()
->withPlaintextToken($plaintextToken)
->create([
'workspace_id' => $workspace->id,
]);
// findByToken should find the invitation using the plaintext token
$found = WorkspaceInvitation::findByToken($plaintextToken);
$this->assertNotNull($found);
$this->assertEquals($invitation->id, $found->id);
// Token in database should be hashed
$this->assertTrue(str_starts_with($found->token, '$2y$'));
// Hash::check should verify the plaintext against the stored hash
$this->assertTrue(Hash::check($plaintextToken, $found->token));
}
public function test_verify_token_method(): void
{
$workspace = Workspace::factory()->create();
$plaintextToken = 'plaintext-token-for-verify-method-test';
$invitation = WorkspaceInvitation::factory()
->withPlaintextToken($plaintextToken)
->create([
'workspace_id' => $workspace->id,
]);
$this->assertTrue($invitation->verifyToken($plaintextToken));
$this->assertFalse($invitation->verifyToken('wrong-token'));
}
2026-01-26 21:08:59 +00:00
public function test_static_accept_with_invalid_token_returns_false(): void
{
$user = User::factory()->create();
$result = Workspace::acceptInvitation('invalid-token', $user);
$this->assertFalse($result);
}
public function test_user_already_in_workspace_still_accepts(): void
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
// User already in workspace
$workspace->users()->attach($user->id, ['role' => 'member']);
$invitation = WorkspaceInvitation::factory()->create([
'workspace_id' => $workspace->id,
'email' => $user->email,
'role' => 'admin',
]);
$result = $invitation->accept($user);
$this->assertTrue($result);
$this->assertTrue($invitation->fresh()->isAccepted());
// Role should remain as original (member), not updated to admin
$this->assertEquals('member', $workspace->users()->find($user->id)->pivot->role);
}
public function test_invitation_scopes(): void
{
$workspace = Workspace::factory()->create();
$pending = WorkspaceInvitation::factory()->create([
'workspace_id' => $workspace->id,
]);
$expired = WorkspaceInvitation::factory()->expired()->create([
'workspace_id' => $workspace->id,
]);
$accepted = WorkspaceInvitation::factory()->accepted()->create([
'workspace_id' => $workspace->id,
]);
$this->assertEquals(1, WorkspaceInvitation::pending()->count());
$this->assertEquals(1, WorkspaceInvitation::expired()->count());
$this->assertEquals(1, WorkspaceInvitation::accepted()->count());
}
// ─────────────────────────────────────────────────────────────────────────
// Bulk Invitation Tests (inviteMany)
// ─────────────────────────────────────────────────────────────────────────
public function test_invite_many_sends_multiple_invitations(): void
{
Notification::fake();
$owner = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($owner->id, ['role' => 'owner']);
$emails = [
'alice@example.com',
'bob@example.com',
'charlie@example.com',
];
$results = $workspace->inviteMany($emails, 'member', $owner);
$this->assertCount(3, $results);
foreach ($results as $result) {
$this->assertEquals('invited', $result['status']);
$this->assertInstanceOf(WorkspaceInvitation::class, $result['invitation']);
}
$this->assertEquals(3, $workspace->invitations()->count());
Notification::assertSentTimes(WorkspaceInvitationNotification::class, 3);
}
public function test_invite_many_skips_existing_members(): void
{
Notification::fake();
$owner = User::factory()->create();
$existingMember = User::factory()->create(['email' => 'existing@example.com']);
$workspace = Workspace::factory()->create();
$workspace->users()->attach($owner->id, ['role' => 'owner']);
$workspace->users()->attach($existingMember->id, ['role' => 'member']);
$emails = [
'existing@example.com',
'newuser@example.com',
];
$results = $workspace->inviteMany($emails, 'member', $owner);
$this->assertCount(2, $results);
// First result should be skipped (already a member)
$this->assertEquals('existing@example.com', $results[0]['email']);
$this->assertEquals('already_member', $results[0]['status']);
$this->assertNull($results[0]['invitation']);
// Second result should be invited
$this->assertEquals('newuser@example.com', $results[1]['email']);
$this->assertEquals('invited', $results[1]['status']);
$this->assertInstanceOf(WorkspaceInvitation::class, $results[1]['invitation']);
// Only one invitation should be created
$this->assertEquals(1, $workspace->invitations()->count());
Notification::assertSentTimes(WorkspaceInvitationNotification::class, 1);
}
public function test_invite_many_deduplicates_emails(): void
{
Notification::fake();
$workspace = Workspace::factory()->create();
$emails = [
'duplicate@example.com',
'DUPLICATE@example.com',
' duplicate@example.com ',
'unique@example.com',
];
$results = $workspace->inviteMany($emails);
// Should deduplicate to 2 unique emails
$this->assertCount(2, $results);
$this->assertEquals(2, $workspace->invitations()->count());
Notification::assertSentTimes(WorkspaceInvitationNotification::class, 2);
}
public function test_invite_many_with_empty_array(): void
{
Notification::fake();
$workspace = Workspace::factory()->create();
$results = $workspace->inviteMany([]);
$this->assertCount(0, $results);
$this->assertEquals(0, $workspace->invitations()->count());
Notification::assertNothingSent();
}
public function test_invite_many_assigns_correct_role(): void
{
Notification::fake();
$workspace = Workspace::factory()->create();
$emails = ['admin1@example.com', 'admin2@example.com'];
$results = $workspace->inviteMany($emails, 'admin');
foreach ($results as $result) {
$this->assertEquals('admin', $result['invitation']->role);
}
}
public function test_invite_many_handles_resend_for_pending_invitations(): void
{
Notification::fake();
$owner = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($owner->id, ['role' => 'owner']);
// First invitation as member
$workspace->invite('resend@example.com', 'member', $owner);
// Bulk invite includes the same email with a different role
$results = $workspace->inviteMany(
['resend@example.com', 'fresh@example.com'],
'admin',
$owner
);
$this->assertCount(2, $results);
// Should still only have 2 invitations (1 updated + 1 new)
$this->assertEquals(2, $workspace->invitations()->count());
// The resent invitation should have the updated role
$resendResult = $results->firstWhere('email', 'resend@example.com');
$this->assertEquals('invited', $resendResult['status']);
$this->assertEquals('admin', $resendResult['invitation']->role);
}
public function test_invite_many_skips_existing_member_case_insensitive(): void
{
Notification::fake();
$owner = User::factory()->create();
$existingMember = User::factory()->create(['email' => 'Member@Example.COM']);
$workspace = Workspace::factory()->create();
$workspace->users()->attach($owner->id, ['role' => 'owner']);
$workspace->users()->attach($existingMember->id, ['role' => 'member']);
$results = $workspace->inviteMany(['member@example.com']);
$this->assertCount(1, $results);
$this->assertEquals('already_member', $results[0]['status']);
$this->assertNull($results[0]['invitation']);
}
2026-01-26 21:08:59 +00:00
}