php-tenant/tests/Feature/AccountDeletionTest.php
Snider d0ad2737cb refactor: rename namespace from Core\Mod\Tenant to Core\Tenant
Simplifies the namespace hierarchy by removing the intermediate Mod
segment. Updates all 118 files including models, services, controllers,
middleware, tests, and composer.json autoload configuration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 16:30:46 +00:00

334 lines
12 KiB
PHP

<?php
declare(strict_types=1);
use Core\Tenant\Jobs\ProcessAccountDeletion;
use Core\Tenant\Models\AccountDeletionRequest;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
Cache::flush();
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create();
$this->workspace->users()->attach($this->user->id, [
'role' => 'owner',
'is_default' => true,
]);
});
describe('AccountDeletionRequest Model', function () {
describe('createForUser()', function () {
it('creates a new deletion request', function () {
$request = AccountDeletionRequest::createForUser($this->user);
expect($request)->toBeInstanceOf(AccountDeletionRequest::class)
->and($request->user_id)->toBe($this->user->id)
->and($request->token)->toHaveLength(64)
->and($request->completed_at)->toBeNull()
->and($request->cancelled_at)->toBeNull();
});
it('sets expiry based on configured grace period', function () {
config(['tenant.deletion.grace_period_days' => 14]);
$this->travelTo(now()->startOfDay());
$request = AccountDeletionRequest::createForUser($this->user);
// Expiry should be 14 days in the future
expect((int) abs($request->expires_at->startOfDay()->diffInDays(now()->startOfDay())))->toBe(14);
});
it('stores optional reason', function () {
$reason = 'Switching to competitor';
$request = AccountDeletionRequest::createForUser($this->user, $reason);
expect($request->reason)->toBe($reason);
});
it('cancels existing pending requests', function () {
$oldRequest = AccountDeletionRequest::createForUser($this->user);
$oldRequestId = $oldRequest->id;
$newRequest = AccountDeletionRequest::createForUser($this->user);
expect(AccountDeletionRequest::find($oldRequestId))->toBeNull()
->and($newRequest->id)->not->toBe($oldRequestId);
});
it('does not affect completed requests', function () {
$completedRequest = AccountDeletionRequest::createForUser($this->user);
$completedRequest->complete();
$newRequest = AccountDeletionRequest::createForUser($this->user);
expect(AccountDeletionRequest::find($completedRequest->id))->not->toBeNull()
->and($newRequest->id)->not->toBe($completedRequest->id);
});
});
describe('findValidByToken()', function () {
it('finds valid request by token', function () {
$request = AccountDeletionRequest::createForUser($this->user);
$found = AccountDeletionRequest::findValidByToken($request->token);
expect($found)->not->toBeNull()
->and($found->id)->toBe($request->id);
});
it('returns null for completed request', function () {
$request = AccountDeletionRequest::createForUser($this->user);
$request->complete();
$found = AccountDeletionRequest::findValidByToken($request->token);
expect($found)->toBeNull();
});
it('returns null for cancelled request', function () {
$request = AccountDeletionRequest::createForUser($this->user);
$request->cancel();
$found = AccountDeletionRequest::findValidByToken($request->token);
expect($found)->toBeNull();
});
it('returns null for invalid token', function () {
AccountDeletionRequest::createForUser($this->user);
$found = AccountDeletionRequest::findValidByToken('invalid-token');
expect($found)->toBeNull();
});
});
describe('pendingAutoDelete()', function () {
it('returns requests past expiry date', function () {
$request = AccountDeletionRequest::createForUser($this->user);
$request->update(['expires_at' => now()->subDay()]);
$pending = AccountDeletionRequest::pendingAutoDelete()->get();
expect($pending)->toHaveCount(1)
->and($pending->first()->id)->toBe($request->id);
});
it('excludes requests not yet expired', function () {
AccountDeletionRequest::createForUser($this->user);
$pending = AccountDeletionRequest::pendingAutoDelete()->get();
expect($pending)->toHaveCount(0);
});
it('excludes completed requests', function () {
$request = AccountDeletionRequest::createForUser($this->user);
$request->update(['expires_at' => now()->subDay()]);
$request->complete();
$pending = AccountDeletionRequest::pendingAutoDelete()->get();
expect($pending)->toHaveCount(0);
});
it('excludes cancelled requests', function () {
$request = AccountDeletionRequest::createForUser($this->user);
$request->update(['expires_at' => now()->subDay()]);
$request->cancel();
$pending = AccountDeletionRequest::pendingAutoDelete()->get();
expect($pending)->toHaveCount(0);
});
});
describe('state methods', function () {
it('isActive returns true for pending requests', function () {
$request = AccountDeletionRequest::createForUser($this->user);
expect($request->isActive())->toBeTrue();
});
it('isActive returns false after completion', function () {
$request = AccountDeletionRequest::createForUser($this->user);
$request->complete();
expect($request->isActive())->toBeFalse();
});
it('isActive returns false after cancellation', function () {
$request = AccountDeletionRequest::createForUser($this->user);
$request->cancel();
expect($request->isActive())->toBeFalse();
});
it('isPending returns true for future expiry', function () {
$request = AccountDeletionRequest::createForUser($this->user);
expect($request->isPending())->toBeTrue();
});
it('isReadyForAutoDeletion returns true for past expiry', function () {
$request = AccountDeletionRequest::createForUser($this->user);
$request->update(['expires_at' => now()->subDay()]);
expect($request->isReadyForAutoDeletion())->toBeTrue();
});
});
describe('time helpers', function () {
it('calculates days remaining approximately', function () {
$this->travelTo(now()->startOfDay());
$request = AccountDeletionRequest::createForUser($this->user);
$request->update(['expires_at' => now()->startOfDay()->addDays(5)]);
// Use startOfDay to avoid timing issues
expect($request->daysRemaining())->toBeGreaterThanOrEqual(4)
->and($request->daysRemaining())->toBeLessThanOrEqual(5);
});
it('calculates hours remaining approximately', function () {
$this->travelTo(now()->startOfHour());
$request = AccountDeletionRequest::createForUser($this->user);
$request->update(['expires_at' => now()->startOfHour()->addHours(48)]);
expect($request->hoursRemaining())->toBeGreaterThanOrEqual(47)
->and($request->hoursRemaining())->toBeLessThanOrEqual(48);
});
it('returns zero for past expiry', function () {
$request = AccountDeletionRequest::createForUser($this->user);
$request->update(['expires_at' => now()->subDays(2)]);
expect($request->daysRemaining())->toBe(0)
->and($request->hoursRemaining())->toBe(0);
});
});
describe('URL helpers', function () {
it('generates confirmation URL with token', function () {
$request = AccountDeletionRequest::createForUser($this->user);
$url = $request->confirmationUrl();
expect($url)->toContain($request->token)
->and($url)->toContain('account/delete');
});
it('generates cancel URL with token', function () {
$request = AccountDeletionRequest::createForUser($this->user);
$url = $request->cancelUrl();
expect($url)->toContain($request->token)
->and($url)->toContain('cancel');
});
});
});
describe('ProcessAccountDeletion Job', function () {
it('deletes user account', function () {
$request = AccountDeletionRequest::createForUser($this->user);
$request->update(['expires_at' => now()->subDay()]);
$job = new ProcessAccountDeletion($request);
$job->handle();
// User should be deleted
expect(User::find($this->user->id))->toBeNull();
// Note: AccountDeletionRequest is also deleted due to CASCADE constraint
// This is expected behaviour as we want the request deleted when user is deleted
});
it('deletes user workspaces', function () {
$request = AccountDeletionRequest::createForUser($this->user);
$request->update(['expires_at' => now()->subDay()]);
$workspaceId = $this->workspace->id;
$job = new ProcessAccountDeletion($request);
$job->handle();
expect(Workspace::find($workspaceId))->toBeNull();
});
it('skips if request no longer active', function () {
$request = AccountDeletionRequest::createForUser($this->user);
$request->cancel();
$job = new ProcessAccountDeletion($request);
$job->handle();
expect(User::find($this->user->id))->not->toBeNull();
});
it('handles missing user gracefully', function () {
$request = AccountDeletionRequest::createForUser($this->user);
$this->user->forceDelete();
// Request is deleted due to CASCADE, job should handle this gracefully
$job = new ProcessAccountDeletion($request);
// Should not throw
$job->handle();
// Just verify user is still gone
expect(User::find($this->user->id))->toBeNull();
});
});
describe('ProcessAccountDeletions Command', function () {
it('processes expired deletion requests', function () {
$request = AccountDeletionRequest::createForUser($this->user);
$request->update(['expires_at' => now()->subDay()]);
$this->artisan('accounts:process-deletions')
->assertSuccessful()
->expectsOutputToContain('1 deleted');
expect(User::find($this->user->id))->toBeNull();
});
it('skips non-expired requests', function () {
AccountDeletionRequest::createForUser($this->user);
$this->artisan('accounts:process-deletions')
->assertSuccessful()
->expectsOutputToContain('No pending account deletions');
expect(User::find($this->user->id))->not->toBeNull();
});
it('supports dry-run mode', function () {
$request = AccountDeletionRequest::createForUser($this->user);
$request->update(['expires_at' => now()->subDay()]);
$this->artisan('accounts:process-deletions', ['--dry-run' => true])
->assertSuccessful()
->expectsOutputToContain('DRY RUN');
// User should still exist
expect(User::find($this->user->id))->not->toBeNull();
});
it('supports queue mode', function () {
Queue::fake();
$request = AccountDeletionRequest::createForUser($this->user);
$request->update(['expires_at' => now()->subDay()]);
$this->artisan('accounts:process-deletions', ['--queue' => true])
->assertSuccessful()
->expectsOutputToContain('queued');
Queue::assertPushed(ProcessAccountDeletion::class);
});
});