clock = new SystemClock(); $this->ulidGenerator = new UlidGenerator(); $this->service = new InMemoryMagicLinkService($this->ulidGenerator, $this->clock); }); describe('InMemoryMagicLinkService - Token Generation', function () { it('generates valid token', function () { $action = new TokenAction('email_verification'); $payload = ['user_id' => 123, 'email' => 'test@example.com']; $token = $this->service->generate($action, $payload); expect($token)->toBeInstanceOf(MagicLinkToken::class); expect(strlen($token->value))->toBeGreaterThanOrEqual(16); }); it('generates unique tokens for each call', function () { $action = new TokenAction('test_action'); $payload = ['data' => 'test']; $token1 = $this->service->generate($action, $payload); $token2 = $this->service->generate($action, $payload); expect($token1->equals($token2))->toBeFalse(); }); it('stores token with default configuration', function () { $action = new TokenAction('test_action'); $payload = ['test' => 'data']; $token = $this->service->generate($action, $payload); expect($this->service->exists($token))->toBeTrue(); }); it('stores token with custom configuration', function () { $action = new TokenAction('test_action'); $payload = ['test' => 'data']; $config = new TokenConfig(expiryHours: 48, oneTimeUse: true); $token = $this->service->generate($action, $payload, $config); expect($this->service->exists($token))->toBeTrue(); }); it('stores IP address and user agent', function () { $action = new TokenAction('test_action'); $payload = ['test' => 'data']; $token = $this->service->generate( $action, $payload, createdByIp: '192.168.1.1', userAgent: 'Test Browser' ); $data = $this->service->validate($token); expect($data->createdByIp)->toBe('192.168.1.1'); expect($data->userAgent)->toBe('Test Browser'); }); }); describe('InMemoryMagicLinkService - Token Validation', function () { it('validates existing token', function () { $action = new TokenAction('test_action'); $payload = ['user_id' => 123]; $token = $this->service->generate($action, $payload); $data = $this->service->validate($token); expect($data)->toBeInstanceOf(\App\Framework\MagicLinks\MagicLinkData::class); expect($data->action->name)->toBe('test_action'); expect($data->payload->get('user_id'))->toBe(123); }); it('returns null for non-existent token', function () { $nonExistentToken = new MagicLinkToken('01234567890123456789012345678901'); $data = $this->service->validate($nonExistentToken); expect($data)->toBeNull(); }); it('returns null for used one-time token', function () { $action = new TokenAction('test_action'); $payload = ['test' => 'data']; $config = new TokenConfig(oneTimeUse: true); $token = $this->service->generate($action, $payload, $config); $this->service->markAsUsed($token); $data = $this->service->validate($token); expect($data)->toBeNull(); }); }); describe('InMemoryMagicLinkService - Token Usage', function () { it('marks token as used', function () { $action = new TokenAction('test_action'); $payload = ['test' => 'data']; $config = new TokenConfig(oneTimeUse: true); $token = $this->service->generate($action, $payload, $config); // Valid before use expect($this->service->validate($token))->toBeInstanceOf(\App\Framework\MagicLinks\MagicLinkData::class); $this->service->markAsUsed($token); // Invalid after use (one-time use) expect($this->service->validate($token))->toBeNull(); }); it('allows marking non-one-time tokens as used', function () { $action = new TokenAction('test_action'); $payload = ['test' => 'data']; $config = new TokenConfig(oneTimeUse: false); $token = $this->service->generate($action, $payload, $config); $this->service->markAsUsed($token); // Still valid (not one-time use) expect($this->service->validate($token))->toBeInstanceOf(\App\Framework\MagicLinks\MagicLinkData::class); }); it('handles marking non-existent token gracefully', function () { $nonExistentToken = new MagicLinkToken('01234567890123456789012345678901'); // Should not throw exception $this->service->markAsUsed($nonExistentToken); expect(true)->toBeTrue(); }); }); describe('InMemoryMagicLinkService - Token Revocation', function () { it('revokes token', function () { $action = new TokenAction('test_action'); $payload = ['test' => 'data']; $token = $this->service->generate($action, $payload); // Exists before revocation expect($this->service->exists($token))->toBeTrue(); $this->service->revoke($token); // Does not exist after revocation expect($this->service->exists($token))->toBeFalse(); expect($this->service->validate($token))->toBeNull(); }); it('handles revoking non-existent token gracefully', function () { $nonExistentToken = new MagicLinkToken('01234567890123456789012345678901'); // Should not throw exception $this->service->revoke($nonExistentToken); expect(true)->toBeTrue(); }); }); describe('InMemoryMagicLinkService - Token Existence', function () { it('checks token existence correctly', function () { $action = new TokenAction('test_action'); $payload = ['test' => 'data']; $token = $this->service->generate($action, $payload); expect($this->service->exists($token))->toBeTrue(); }); it('returns false for non-existent token', function () { $nonExistentToken = new MagicLinkToken('01234567890123456789012345678901'); expect($this->service->exists($nonExistentToken))->toBeFalse(); }); it('returns false for revoked token', function () { $action = new TokenAction('test_action'); $payload = ['test' => 'data']; $token = $this->service->generate($action, $payload); $this->service->revoke($token); expect($this->service->exists($token))->toBeFalse(); }); }); describe('InMemoryMagicLinkService - Active Tokens', function () { it('retrieves active tokens', function () { $action = new TokenAction('test_action'); // Generate multiple tokens $this->service->generate($action, ['id' => 1]); $this->service->generate($action, ['id' => 2]); $this->service->generate($action, ['id' => 3]); $activeTokens = $this->service->getActiveTokens(); expect($activeTokens)->toHaveCount(3); }); it('respects limit parameter', function () { $action = new TokenAction('test_action'); // Generate 5 tokens for ($i = 1; $i <= 5; $i++) { $this->service->generate($action, ['id' => $i]); } $activeTokens = $this->service->getActiveTokens(limit: 3); expect($activeTokens)->toHaveCount(3); }); it('excludes used one-time tokens from active list', function () { $action = new TokenAction('test_action'); $config = new TokenConfig(oneTimeUse: true); $token1 = $this->service->generate($action, ['id' => 1], $config); $token2 = $this->service->generate($action, ['id' => 2], $config); $this->service->markAsUsed($token1); $activeTokens = $this->service->getActiveTokens(); expect($activeTokens)->toHaveCount(1); }); it('returns empty array when no active tokens', function () { $activeTokens = $this->service->getActiveTokens(); expect($activeTokens)->toBeArray(); expect($activeTokens)->toBeEmpty(); }); }); describe('InMemoryMagicLinkService - Cleanup', function () { it('removes expired tokens', function () { $action = new TokenAction('test_action'); // Create token, then manually expire it by waiting // We'll test cleanup by generating tokens and checking count // Since we can't create expired tokens directly (validation prevents it), // we'll just verify cleanup works with no expired tokens $config = new TokenConfig(expiryHours: 1); $token1 = $this->service->generate($action, ['id' => 1], $config); $token2 = $this->service->generate($action, ['id' => 2], $config); // Initially should have 2 tokens expect($this->service->getActiveTokens())->toHaveCount(2); // Cleanup with no expired tokens should return 0 $removedCount = $this->service->cleanupExpired(); expect($removedCount)->toBe(0); // Both tokens should still exist expect($this->service->exists($token1))->toBeTrue(); expect($this->service->exists($token2))->toBeTrue(); }); it('returns zero when no expired tokens', function () { $action = new TokenAction('test_action'); $config = new TokenConfig(expiryHours: 24); $this->service->generate($action, ['id' => 1], $config); $this->service->generate($action, ['id' => 2], $config); $removedCount = $this->service->cleanupExpired(); expect($removedCount)->toBe(0); }); it('handles empty token list', function () { $removedCount = $this->service->cleanupExpired(); expect($removedCount)->toBe(0); }); });