php-tenant/tests/Feature/AccountDeletionTest.php

335 lines
12 KiB
PHP
Raw Permalink Normal View History

2026-01-26 21:08:59 +00:00
<?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;
2026-01-26 21:08:59 +00:00
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);
});
});