from datetime import datetime from itertools import count from gapido_auth.domain.entities import RefreshSession, Role, SmsJob, User from gapido_auth.domain.errors import OtpAttemptsExceeded, OtpExpired class FakeUserRepository: def __init__(self) -> None: self._users_by_mobile: dict[str, User] = {} self._ids = count(1) async def get_by_id(self, user_id: str) -> User | None: return next((user for user in self._users_by_mobile.values() if user.id == user_id), None) async def get_by_mobile(self, mobile: str) -> User | None: return self._users_by_mobile.get(mobile) async def get_or_create_user(self, mobile: str, role: Role = Role.USER) -> User: existing = self._users_by_mobile.get(mobile) if existing: return existing now = datetime.now() user = User( id=str(next(self._ids)), mobile=mobile, role=role, is_active=True, created_at=now, updated_at=now, ) self._users_by_mobile[mobile] = user return user async def ensure_admin_user(self, mobile: str) -> User: now = datetime.now() user = User( id=str(next(self._ids)), mobile=mobile, role=Role.ADMIN, is_active=True, created_at=now, updated_at=now, ) self._users_by_mobile[mobile] = user return user class FakeRefreshSessionRepository: def __init__(self) -> None: self._sessions: dict[str, RefreshSession] = {} self._ids = count(1) async def create(self, user_id: str, token_hash: str, expires_at: datetime) -> RefreshSession: session = RefreshSession( id=str(next(self._ids)), user_id=user_id, token_hash=token_hash, expires_at=expires_at, revoked_at=None, replaced_by_hash=None, created_at=datetime.now(), ) self._sessions[token_hash] = session return session async def get_active_by_hash(self, token_hash: str, now: datetime) -> RefreshSession | None: session = self._sessions.get(token_hash) if session is None or session.revoked_at is not None or session.expires_at <= now: return None return session async def revoke( self, token_hash: str, now: datetime, replaced_by_hash: str | None = None ) -> None: session = self._sessions.get(token_hash) if session is None or session.revoked_at is not None: return self._sessions[token_hash] = RefreshSession( id=session.id, user_id=session.user_id, token_hash=session.token_hash, expires_at=session.expires_at, revoked_at=now, replaced_by_hash=replaced_by_hash, created_at=session.created_at, ) class FakeOtpStore: def __init__(self) -> None: self.requests: dict[str, int] = {} self.otps: dict[tuple[str, str], tuple[str, int, int]] = {} async def allow_request(self, key: str, limit: int, window_seconds: int) -> bool: self.requests[key] = self.requests.get(key, 0) + 1 return self.requests[key] <= limit async def store_otp( self, mobile: str, purpose: str, otp_hash: str, ttl_seconds: int, max_attempts: int, ) -> None: self.otps[(mobile, purpose)] = (otp_hash, 0, max_attempts) async def verify_otp(self, mobile: str, purpose: str, candidate_hash: str) -> bool: key = (mobile, purpose) if key not in self.otps: raise OtpExpired("missing") stored_hash, attempts, max_attempts = self.otps[key] attempts += 1 if attempts > max_attempts: del self.otps[key] raise OtpAttemptsExceeded("too many") self.otps[key] = (stored_hash, attempts, max_attempts) if stored_hash != candidate_hash: return False del self.otps[key] return True class FakeSmsPublisher: def __init__(self) -> None: self.jobs: list[SmsJob] = [] async def publish(self, job: SmsJob) -> None: self.jobs.append(job)