tokenRepository = new InMemoryActivationTokenRepository(); $this->passwordHasher = new class implements PasswordHasher { #[Override] public function hash(string $plainPassword): string { return '$argon2id$hashed_password'; } #[Override] public function verify(string $hashedPassword, string $plainPassword): bool { return true; } }; $this->clock = new class implements Clock { public DateTimeImmutable $now; public function __construct() { $this->now = new DateTimeImmutable('2026-01-16 10:00:00'); } #[Override] public function now(): DateTimeImmutable { return $this->now; } }; $this->handler = new ActivateAccountHandler( $this->tokenRepository, $this->passwordHasher, $this->clock, ); } #[Test] public function activateAccountSuccessfully(): void { $token = $this->createAndSaveToken(); $command = new ActivateAccountCommand( tokenValue: $token->tokenValue, password: self::PASSWORD, ); $result = ($this->handler)($command); self::assertInstanceOf(ActivateAccountResult::class, $result); self::assertSame(self::USER_ID, $result->userId); self::assertSame(self::EMAIL, $result->email); self::assertSame(self::ROLE, $result->role); self::assertSame(self::HASHED_PASSWORD, $result->hashedPassword); } #[Test] public function activateAccountValidatesButDoesNotConsumeToken(): void { // Handler only validates the token - consumption is deferred to the processor // after successful user activation, so failed activations don't burn the token $token = $this->createAndSaveToken(); $tokenValue = $token->tokenValue; $command = new ActivateAccountCommand( tokenValue: $tokenValue, password: self::PASSWORD, ); ($this->handler)($command); // Token should still exist and NOT be marked as used $updatedToken = $this->tokenRepository->findByTokenValue($tokenValue); self::assertNotNull($updatedToken); self::assertFalse($updatedToken->isUsed()); } #[Test] public function activateAccountThrowsWhenTokenNotFound(): void { $command = new ActivateAccountCommand( tokenValue: 'non-existent-token', password: self::PASSWORD, ); $this->expectException(ActivationTokenNotFoundException::class); ($this->handler)($command); } #[Test] public function activateAccountThrowsWhenTokenExpired(): void { $token = $this->createAndSaveToken( createdAt: new DateTimeImmutable('2026-01-01 10:00:00'), ); // Clock is set to 2026-01-16, token expires 2026-01-08 $command = new ActivateAccountCommand( tokenValue: $token->tokenValue, password: self::PASSWORD, ); $this->expectException(ActivationTokenExpiredException::class); ($this->handler)($command); } #[Test] public function activateAccountThrowsWhenTokenAlreadyUsed(): void { $token = $this->createAndSaveToken(); // Simulate a token that was already used (e.g., by the processor after successful activation) $token->use($this->clock->now()); $this->tokenRepository->save($token); $command = new ActivateAccountCommand( tokenValue: $token->tokenValue, password: self::PASSWORD, ); // Should fail because token is already used $this->expectException(ActivationTokenAlreadyUsedException::class); ($this->handler)($command); } private function createAndSaveToken(?DateTimeImmutable $createdAt = null): ActivationToken { $token = ActivationToken::generate( userId: self::USER_ID, email: self::EMAIL, tenantId: TenantId::fromString(self::TENANT_ID), role: self::ROLE, schoolName: self::SCHOOL_NAME, createdAt: $createdAt ?? new DateTimeImmutable('2026-01-15 10:00:00'), ); $this->tokenRepository->save($token); return $token; } }