now = new DateTimeImmutable(); $this->futureExpiry = $this->now->modify('+1 hour'); $this->pastExpiry = $this->now->modify('-1 hour'); }); it('creates valid magiclink data', function () { $action = new TokenAction('email_verification'); $payload = MagicLinkPayload::fromArray(['user_id' => 123, 'email' => 'test@example.com']); $data = new MagicLinkData( id: 'test-id-123', action: $action, payload: $payload, expiresAt: $this->futureExpiry, createdAt: $this->now ); expect($data->id)->toBe('test-id-123'); expect($data->action)->toBe($action); expect($data->payload)->toBe($payload); expect($data->expiresAt)->toBe($this->futureExpiry); expect($data->createdAt)->toBe($this->now); expect($data->oneTimeUse)->toBeFalse(); expect($data->isUsed)->toBeFalse(); expect($data->usedAt)->toBeNull(); }); it('detects expired tokens', function () { $data = new MagicLinkData( id: 'expired-token', action: new TokenAction('test_action'), payload: MagicLinkPayload::fromArray(['test' => 'data']), expiresAt: $this->pastExpiry, createdAt: $this->now ); expect($data->isExpired())->toBeTrue(); expect($data->isValid())->toBeFalse(); }); it('detects valid non-expired tokens', function () { $data = new MagicLinkData( id: 'valid-token', action: new TokenAction('test_action'), payload: MagicLinkPayload::fromArray(['test' => 'data']), expiresAt: $this->futureExpiry, createdAt: $this->now ); expect($data->isExpired())->toBeFalse(); expect($data->isValid())->toBeTrue(); }); it('validates one-time use tokens', function () { $data = new MagicLinkData( id: 'one-time-token', action: new TokenAction('test_action'), payload: MagicLinkPayload::fromArray(['test' => 'data']), expiresAt: $this->futureExpiry, createdAt: $this->now, oneTimeUse: true, isUsed: false ); expect($data->isValid())->toBeTrue(); // After marking as used $usedData = $data->withUsed(new DateTimeImmutable()); expect($usedData->isValid())->toBeFalse(); expect($usedData->isUsed)->toBeTrue(); }); it('calculates remaining time correctly', function () { $oneHourFuture = $this->now->modify('+1 hour'); $data = new MagicLinkData( id: 'test-token', action: new TokenAction('test_action'), payload: MagicLinkPayload::fromArray(['test' => 'data']), expiresAt: $oneHourFuture, createdAt: $this->now ); $remaining = $data->getRemainingTime(); // Should be approximately 1 hour (allowing for small execution time variance) expect($remaining->h)->toBeGreaterThanOrEqual(0); expect($remaining->i)->toBeGreaterThan(50); // At least 50 minutes }); it('returns zero interval for expired tokens', function () { $data = new MagicLinkData( id: 'expired-token', action: new TokenAction('test_action'), payload: MagicLinkPayload::fromArray(['test' => 'data']), expiresAt: $this->pastExpiry, createdAt: $this->now ); $remaining = $data->getRemainingTime(); expect($remaining->d)->toBe(0); // d = day component expect($remaining->h)->toBe(0); // h = hour component expect($remaining->i)->toBe(0); // i = minute component }); it('marks token as used immutably', function () { $originalData = new MagicLinkData( id: 'test-token', action: new TokenAction('test_action'), payload: MagicLinkPayload::fromArray(['test' => 'data']), expiresAt: $this->futureExpiry, createdAt: $this->now, oneTimeUse: true ); $usedAt = new DateTimeImmutable('+10 seconds'); $usedData = $originalData->withUsed($usedAt); // Original unchanged expect($originalData->isUsed)->toBeFalse(); expect($originalData->usedAt)->toBeNull(); // New instance has updated values expect($usedData->isUsed)->toBeTrue(); expect($usedData->usedAt)->toBe($usedAt); expect($usedData->id)->toBe($originalData->id); expect($usedData->action)->toBe($originalData->action); }); it('stores IP address and user agent', function () { $data = new MagicLinkData( id: 'test-token', action: new TokenAction('test_action'), payload: MagicLinkPayload::fromArray(['test' => 'data']), expiresAt: $this->futureExpiry, createdAt: $this->now, createdByIp: '192.168.1.1', userAgent: 'Mozilla/5.0 Test Browser' ); expect($data->createdByIp)->toBe('192.168.1.1'); expect($data->userAgent)->toBe('Mozilla/5.0 Test Browser'); }); });