createTestUser(); $doctrine = $this->createMock(UserRepository::class); $doctrine->expects(self::once())->method('save')->with($user); $cache = $this->createStubCachePool(); $repository = new CachedUserRepository($doctrine, $cache); $repository->save($user); } #[Test] public function saveUpdatesRedisAfterDoctrine(): void { $user = $this->createTestUser(); $savedKeys = []; $cacheItem = $this->createMock(CacheItemInterface::class); $cacheItem->method('set')->willReturnSelf(); $cacheItem->method('isHit')->willReturn(false); $cacheItem->method('get')->willReturn([]); $cache = $this->createMock(CacheItemPoolInterface::class); $cache->method('getItem') ->willReturnCallback(static function (string $key) use (&$savedKeys, $cacheItem) { $savedKeys[] = $key; return $cacheItem; }); $cache->method('save')->willReturn(true); $doctrine = $this->createMock(UserRepository::class); $repository = new CachedUserRepository($doctrine, $cache); $repository->save($user); self::assertNotEmpty($savedKeys); self::assertContains('user:' . $user->id, $savedKeys); } #[Test] public function findByIdReturnsCachedUserOnHit(): void { $userId = UserId::fromString('550e8400-e29b-41d4-a716-446655440001'); $userData = $this->makeSerializedUser('550e8400-e29b-41d4-a716-446655440001'); $cacheItem = $this->createMock(CacheItemInterface::class); $cacheItem->method('isHit')->willReturn(true); $cacheItem->method('get')->willReturn($userData); $cache = $this->createMock(CacheItemPoolInterface::class); $cache->method('getItem')->willReturn($cacheItem); $doctrine = $this->createMock(UserRepository::class); // Doctrine should NOT be called on cache hit $doctrine->expects(self::never())->method('findById'); $repository = new CachedUserRepository($doctrine, $cache); $user = $repository->findById($userId); self::assertNotNull($user); self::assertSame('550e8400-e29b-41d4-a716-446655440001', (string) $user->id); } #[Test] public function findByIdFallsBackToDoctrineOnCacheMiss(): void { $userId = UserId::fromString('550e8400-e29b-41d4-a716-446655440001'); $missItem = $this->createMock(CacheItemInterface::class); $missItem->method('isHit')->willReturn(false); $missItem->method('set')->willReturnSelf(); $cache = $this->createMock(CacheItemPoolInterface::class); $cache->method('getItem')->willReturn($missItem); $cache->method('save')->willReturn(true); $expectedUser = $this->createTestUser(); $doctrine = $this->createMock(UserRepository::class); $doctrine->expects(self::once())->method('findById')->willReturn($expectedUser); $repository = new CachedUserRepository($doctrine, $cache); $user = $repository->findById($userId); self::assertNotNull($user); } #[Test] public function findByIdFallsBackToDoctrineWhenRedisUnavailable(): void { $userId = UserId::fromString('550e8400-e29b-41d4-a716-446655440001'); $cache = $this->createMock(CacheItemPoolInterface::class); $cache->method('getItem')->willThrowException(new RuntimeException('Redis connection refused')); $expectedUser = $this->createTestUser(); $doctrine = $this->createMock(UserRepository::class); $doctrine->expects(self::once())->method('findById')->willReturn($expectedUser); $repository = new CachedUserRepository($doctrine, $cache); $user = $repository->findById($userId); self::assertNotNull($user); } #[Test] public function saveSucceedsEvenWhenRedisIsDown(): void { $user = $this->createTestUser(); $cache = $this->createMock(CacheItemPoolInterface::class); $cache->method('getItem')->willThrowException(new RuntimeException('Redis down')); $doctrine = $this->createMock(UserRepository::class); $doctrine->expects(self::once())->method('save')->with($user); $repository = new CachedUserRepository($doctrine, $cache); // Should not throw despite Redis being down $repository->save($user); } #[Test] public function findByEmailUsesEmailIndexFromCache(): void { $tenantId = TenantId::fromString(self::TENANT_ALPHA_ID); $email = new Email('test@example.com'); $userId = '550e8400-e29b-41d4-a716-446655440001'; $userData = $this->makeSerializedUser($userId); $emailIndexItem = $this->createMock(CacheItemInterface::class); $emailIndexItem->method('isHit')->willReturn(true); $emailIndexItem->method('get')->willReturn($userId); $userItem = $this->createMock(CacheItemInterface::class); $userItem->method('isHit')->willReturn(true); $userItem->method('get')->willReturn($userData); $cache = $this->createMock(CacheItemPoolInterface::class); $cache->method('getItem') ->willReturnCallback(static function (string $key) use ($emailIndexItem, $userItem, $tenantId) { $expectedEmailKey = 'user_email:' . $tenantId . ':test_at_example_dot_com'; if ($key === $expectedEmailKey) { return $emailIndexItem; } return $userItem; }); $doctrine = $this->createMock(UserRepository::class); $doctrine->expects(self::never())->method('findByEmail'); $repository = new CachedUserRepository($doctrine, $cache); $user = $repository->findByEmail($email, $tenantId); self::assertNotNull($user); } #[Test] public function findByEmailFallsBackToDoctrineOnCacheMiss(): void { $tenantId = TenantId::fromString(self::TENANT_ALPHA_ID); $email = new Email('test@example.com'); $missItem = $this->createMock(CacheItemInterface::class); $missItem->method('isHit')->willReturn(false); $missItem->method('set')->willReturnSelf(); $cache = $this->createMock(CacheItemPoolInterface::class); $cache->method('getItem')->willReturn($missItem); $cache->method('save')->willReturn(true); $expectedUser = $this->createTestUser(); $doctrine = $this->createMock(UserRepository::class); $doctrine->expects(self::once())->method('findByEmail')->willReturn($expectedUser); $repository = new CachedUserRepository($doctrine, $cache); $user = $repository->findByEmail($email, $tenantId); self::assertNotNull($user); } #[Test] public function findAllByTenantAlwaysGoesToDoctrine(): void { $tenantId = TenantId::fromString(self::TENANT_ALPHA_ID); $cache = $this->createStubCachePool(); $doctrine = $this->createMock(UserRepository::class); $doctrine->expects(self::once()) ->method('findAllByTenant') ->with($tenantId) ->willReturn([$this->createTestUser()]); $repository = new CachedUserRepository($doctrine, $cache); $users = $repository->findAllByTenant($tenantId); self::assertCount(1, $users); } #[Test] public function saveInvalidatesOldEmailIndexOnEmailChange(): void { $userId = '550e8400-e29b-41d4-a716-446655440001'; $oldEmail = 'old@example.com'; $newEmail = 'new@example.com'; $oldSerializedUser = $this->makeSerializedUser($userId); $oldSerializedUser['email'] = $oldEmail; $user = User::reconstitute( id: UserId::fromString($userId), email: new Email($newEmail), roles: [Role::PARENT], tenantId: TenantId::fromString(self::TENANT_ALPHA_ID), schoolName: 'École Test', statut: \App\Administration\Domain\Model\User\StatutCompte::EN_ATTENTE, dateNaissance: null, createdAt: new DateTimeImmutable('2026-01-15T10:00:00+00:00'), hashedPassword: null, activatedAt: null, consentementParental: null, firstName: '', lastName: '', ); $existingItem = $this->createMock(CacheItemInterface::class); $existingItem->method('isHit')->willReturn(true); $existingItem->method('get')->willReturn($oldSerializedUser); $existingItem->method('set')->willReturnSelf(); $otherItem = $this->createMock(CacheItemInterface::class); $otherItem->method('isHit')->willReturn(false); $otherItem->method('get')->willReturn([]); $otherItem->method('set')->willReturnSelf(); $deletedKeys = []; $cache = $this->createMock(CacheItemPoolInterface::class); $cache->method('getItem') ->willReturnCallback(static function (string $key) use ($existingItem, $otherItem, $userId) { if ($key === 'user:' . $userId) { return $existingItem; } return $otherItem; }); $cache->method('save')->willReturn(true); $cache->method('deleteItem') ->willReturnCallback(static function (string $key) use (&$deletedKeys) { $deletedKeys[] = $key; return true; }); $doctrine = $this->createMock(UserRepository::class); $repository = new CachedUserRepository($doctrine, $cache); $repository->save($user); // The old email index should have been deleted self::assertNotEmpty($deletedKeys, 'Old email index should be invalidated'); self::assertStringContainsString('user_email:', $deletedKeys[0]); self::assertStringContainsString('old_at_example_dot_com', $deletedKeys[0]); } #[Test] public function deserializeHandlesLegacySingleRoleFormat(): void { $userId = UserId::fromString('550e8400-e29b-41d4-a716-446655440001'); // Legacy format: 'role' key instead of 'roles' $legacyData = $this->makeSerializedUser((string) $userId); unset($legacyData['roles']); $legacyData['role'] = 'ROLE_PROF'; $cacheItem = $this->createMock(CacheItemInterface::class); $cacheItem->method('isHit')->willReturn(true); $cacheItem->method('get')->willReturn($legacyData); $cache = $this->createMock(CacheItemPoolInterface::class); $cache->method('getItem')->willReturn($cacheItem); $doctrine = $this->createMock(UserRepository::class); $repository = new CachedUserRepository($doctrine, $cache); $user = $repository->findById($userId); self::assertNotNull($user); self::assertCount(1, $user->roles); self::assertSame(Role::PROF, $user->roles[0]); } private function createTestUser(): User { return User::creer( email: new Email('test@example.com'), role: Role::PARENT, tenantId: TenantId::fromString(self::TENANT_ALPHA_ID), schoolName: 'École Test', dateNaissance: null, createdAt: new DateTimeImmutable('2026-01-15T10:00:00+00:00'), ); } /** * @return array */ private function makeSerializedUser(string $userId): array { return [ 'id' => $userId, 'email' => 'test@example.com', 'roles' => ['ROLE_PARENT'], 'tenant_id' => self::TENANT_ALPHA_ID, 'school_name' => 'École Test', 'statut' => 'pending', 'hashed_password' => null, 'date_naissance' => null, 'created_at' => '2026-01-15T10:00:00+00:00', 'activated_at' => null, 'first_name' => '', 'last_name' => '', 'invited_at' => null, 'blocked_at' => null, 'blocked_reason' => null, 'consentement_parental' => null, ]; } private function createStubCachePool(): CacheItemPoolInterface { $cacheItem = $this->createMock(CacheItemInterface::class); $cacheItem->method('set')->willReturnSelf(); $cacheItem->method('isHit')->willReturn(false); $cacheItem->method('get')->willReturn([]); $cache = $this->createMock(CacheItemPoolInterface::class); $cache->method('getItem')->willReturn($cacheItem); $cache->method('save')->willReturn(true); return $cache; } }