mantimon-tcg/backend/app/repositories/protocols.py
Cal Corum 7fcb86ff51 Implement UserRepository pattern with dependency injection
- Add UserRepository and LinkedAccountRepository protocols to protocols.py
- Add UserEntry and LinkedAccountEntry DTOs for service layer decoupling
- Implement PostgresUserRepository and PostgresLinkedAccountRepository
- Refactor UserService to use constructor-injected repositories
- Add get_user_service factory and UserServiceDep to API deps
- Update auth.py and users.py endpoints to use UserServiceDep
- Rewrite tests to use FastAPI dependency overrides (no monkey patching)

This follows the established repository pattern used by DeckService and
CollectionService, enabling future offline fork support.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 07:30:16 -06:00

593 lines
16 KiB
Python

"""Repository protocol definitions for Mantimon TCG.
This module defines the abstract interfaces (Protocols) for data access.
Concrete implementations can be PostgreSQL, SQLite, or in-memory storage.
The protocols define WHAT operations are available, not HOW they're implemented.
This enables the offline fork to implement LocalCollectionRepository while
the backend uses PostgresCollectionRepository.
Example:
class CollectionRepository(Protocol):
async def get_all(self, user_id: UUID) -> list[CollectionEntry]: ...
# PostgreSQL implementation
class PostgresCollectionRepository:
def __init__(self, db: AsyncSession): ...
async def get_all(self, user_id: UUID) -> list[CollectionEntry]: ...
# Local/offline implementation
class LocalCollectionRepository:
def __init__(self, storage_path: Path): ...
async def get_all(self, user_id: UUID) -> list[CollectionEntry]: ...
"""
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Protocol
from uuid import UUID
from app.db.models.collection import CardSource
# =============================================================================
# Sentinel Value for Optional Updates
# =============================================================================
# UNSET is used to distinguish between "not provided" (keep existing)
# and "explicitly set to None" (clear the field).
#
# Example usage:
# async def update(description: str | None = UNSET):
# if description is not UNSET:
# # User explicitly provided a value (could be None to clear)
# record.description = description
class _UnsetType:
"""Sentinel class for unset optional parameters."""
__slots__ = ()
def __repr__(self) -> str:
return "UNSET"
def __bool__(self) -> bool:
return False
UNSET: Any = _UnsetType()
# =============================================================================
# Data Transfer Objects (DTOs)
# =============================================================================
# These are storage-agnostic representations used by protocols.
# They decouple the service layer from ORM models.
@dataclass
class CollectionEntry:
"""Storage-agnostic representation of a collection entry.
This DTO decouples the service layer from the ORM model,
allowing different storage backends to return the same structure.
"""
id: UUID
user_id: UUID
card_definition_id: str
quantity: int
source: CardSource
obtained_at: datetime
created_at: datetime
updated_at: datetime
@dataclass
class DeckEntry:
"""Storage-agnostic representation of a deck.
This DTO decouples the service layer from the ORM model.
"""
id: UUID
user_id: UUID
name: str
cards: dict[str, int]
energy_cards: dict[str, int]
is_valid: bool
validation_errors: list[str] | None
is_starter: bool
starter_type: str | None
description: str | None
created_at: datetime
updated_at: datetime
@dataclass
class UserEntry:
"""Storage-agnostic representation of a user account.
This DTO decouples the service layer from the ORM model,
enabling different storage backends (PostgreSQL, SQLite, JSON)
to be used interchangeably.
"""
id: UUID
email: str
display_name: str
avatar_url: str | None
oauth_provider: str
oauth_id: str
is_premium: bool
premium_until: datetime | None
last_login: datetime | None
created_at: datetime
updated_at: datetime
@dataclass
class LinkedAccountEntry:
"""Storage-agnostic representation of a linked OAuth account.
Users can link multiple OAuth providers (e.g., Google + Discord)
to a single account for flexible login options.
"""
id: UUID
user_id: UUID
provider: str
oauth_id: str
email: str | None
display_name: str | None
avatar_url: str | None
linked_at: datetime
# =============================================================================
# Repository Protocols
# =============================================================================
class CollectionRepository(Protocol):
"""Protocol for card collection data access.
Implementations handle storage-specific details (PostgreSQL, SQLite, JSON).
Services use this protocol for business logic without knowing storage details.
All methods are async to support both database and file-based storage.
"""
async def get_all(self, user_id: UUID) -> list[CollectionEntry]:
"""Get all collection entries for a user.
Args:
user_id: The user's UUID.
Returns:
List of all collection entries, ordered by card_definition_id.
"""
...
async def get_by_card(self, user_id: UUID, card_definition_id: str) -> CollectionEntry | None:
"""Get a specific collection entry.
Args:
user_id: The user's UUID.
card_definition_id: The card ID to look up.
Returns:
CollectionEntry if exists, None otherwise.
"""
...
async def get_quantity(self, user_id: UUID, card_definition_id: str) -> int:
"""Get quantity of a specific card owned by user.
Args:
user_id: The user's UUID.
card_definition_id: Card ID to check.
Returns:
Number of copies owned (0 if not owned).
"""
...
async def upsert(
self,
user_id: UUID,
card_definition_id: str,
quantity: int,
source: CardSource,
) -> CollectionEntry:
"""Add or update a collection entry.
If entry exists, increments quantity. Otherwise creates new entry.
Args:
user_id: The user's UUID.
card_definition_id: Card ID to add.
quantity: Number of copies to add.
source: How the cards were obtained.
Returns:
The created or updated CollectionEntry.
"""
...
async def decrement(
self,
user_id: UUID,
card_definition_id: str,
quantity: int,
) -> CollectionEntry | None:
"""Decrement quantity of a collection entry.
If quantity reaches 0, deletes the entry.
Args:
user_id: The user's UUID.
card_definition_id: Card ID to decrement.
quantity: Number of copies to remove.
Returns:
Updated entry, or None if entry was deleted or didn't exist.
"""
...
async def exists_with_source(self, user_id: UUID, source: CardSource) -> bool:
"""Check if user has any entries with the given source.
Useful for checking if user has received a starter deck.
Args:
user_id: The user's UUID.
source: The CardSource to check for.
Returns:
True if any entries exist with that source.
"""
...
class DeckRepository(Protocol):
"""Protocol for deck data access.
Implementations handle storage-specific details (PostgreSQL, SQLite, JSON).
Services use this protocol for business logic without knowing storage details.
"""
async def get_by_id(self, deck_id: UUID) -> DeckEntry | None:
"""Get a deck by its ID.
Args:
deck_id: The deck's UUID.
Returns:
DeckEntry if found, None otherwise.
"""
...
async def get_by_user(self, user_id: UUID) -> list[DeckEntry]:
"""Get all decks for a user.
Args:
user_id: The user's UUID.
Returns:
List of all user's decks, ordered by name.
"""
...
async def get_user_deck(self, user_id: UUID, deck_id: UUID) -> DeckEntry | None:
"""Get a specific deck owned by a user.
Combines ownership check with retrieval.
Args:
user_id: The user's UUID.
deck_id: The deck's UUID.
Returns:
DeckEntry if found and owned by user, None otherwise.
"""
...
async def count_by_user(self, user_id: UUID) -> int:
"""Count how many decks a user has.
Args:
user_id: The user's UUID.
Returns:
Number of decks owned by user.
"""
...
async def create(
self,
user_id: UUID,
name: str,
cards: dict[str, int],
energy_cards: dict[str, int],
is_valid: bool,
validation_errors: list[str] | None,
is_starter: bool = False,
starter_type: str | None = None,
description: str | None = None,
) -> DeckEntry:
"""Create a new deck.
Args:
user_id: The user's UUID.
name: Display name for the deck.
cards: Card ID to quantity mapping.
energy_cards: Energy type to quantity mapping.
is_valid: Whether deck passes validation.
validation_errors: List of validation error messages.
is_starter: Whether this is a starter deck.
starter_type: Type of starter deck if applicable.
description: Optional deck description.
Returns:
The created DeckEntry.
"""
...
async def update(
self,
deck_id: UUID,
name: str | None = None,
cards: dict[str, int] | None = None,
energy_cards: dict[str, int] | None = None,
is_valid: bool | None = None,
validation_errors: list[str] | None = UNSET, # type: ignore[assignment]
description: str | None = UNSET, # type: ignore[assignment]
) -> DeckEntry | None:
"""Update an existing deck.
Only provided (non-UNSET) fields are updated.
Use UNSET (default) to keep existing value, or None to clear.
Args:
deck_id: The deck's UUID.
name: New name (optional, None keeps existing).
cards: New card composition (optional, None keeps existing).
energy_cards: New energy composition (optional, None keeps existing).
is_valid: New validation status (optional, None keeps existing).
validation_errors: New errors (UNSET=keep, None=clear, list=set).
description: New description (UNSET=keep, None=clear, str=set).
Returns:
Updated DeckEntry, or None if deck not found.
"""
...
async def delete(self, deck_id: UUID) -> bool:
"""Delete a deck.
Args:
deck_id: The deck's UUID.
Returns:
True if deleted, False if not found.
"""
...
async def has_starter(self, user_id: UUID) -> tuple[bool, str | None]:
"""Check if user has a starter deck.
Args:
user_id: The user's UUID.
Returns:
Tuple of (has_starter, starter_type).
"""
...
class UserRepository(Protocol):
"""Protocol for user account data access.
Implementations handle storage-specific details (PostgreSQL, SQLite, JSON).
Services use this protocol for business logic without knowing storage details.
Note: Business logic like get_or_create_from_oauth belongs in the service layer,
not in the repository.
"""
async def get_by_id(self, user_id: UUID) -> UserEntry | None:
"""Get a user by their ID.
Args:
user_id: The user's UUID.
Returns:
UserEntry if found, None otherwise.
"""
...
async def get_by_email(self, email: str) -> UserEntry | None:
"""Get a user by their email address.
Args:
email: The user's email address.
Returns:
UserEntry if found, None otherwise.
"""
...
async def get_by_oauth(self, provider: str, oauth_id: str) -> UserEntry | None:
"""Get a user by their OAuth provider and ID.
Args:
provider: OAuth provider name (google, discord).
oauth_id: Unique ID from the OAuth provider.
Returns:
UserEntry if found, None otherwise.
"""
...
async def create(
self,
email: str,
display_name: str,
oauth_provider: str,
oauth_id: str,
avatar_url: str | None = None,
) -> UserEntry:
"""Create a new user.
Args:
email: User's email address.
display_name: Public display name.
oauth_provider: OAuth provider name.
oauth_id: Unique ID from the OAuth provider.
avatar_url: Optional avatar URL.
Returns:
The created UserEntry.
"""
...
async def update(
self,
user_id: UUID,
display_name: str | None = None,
avatar_url: str | None = UNSET, # type: ignore[assignment]
oauth_provider: str | None = None,
oauth_id: str | None = None,
) -> UserEntry | None:
"""Update user profile fields.
Only provided (non-None/non-UNSET) fields are updated.
Use UNSET (default) to keep existing value for nullable fields,
or None to explicitly clear them.
Args:
user_id: The user's UUID.
display_name: New display name (None keeps existing).
avatar_url: New avatar URL (UNSET=keep, None=clear, str=set).
oauth_provider: New OAuth provider (None keeps existing).
oauth_id: New OAuth ID (None keeps existing).
Returns:
Updated UserEntry, or None if user not found.
"""
...
async def update_last_login(self, user_id: UUID) -> UserEntry | None:
"""Update the user's last login timestamp to now.
Args:
user_id: The user's UUID.
Returns:
Updated UserEntry, or None if user not found.
"""
...
async def update_premium(
self,
user_id: UUID,
is_premium: bool,
premium_until: datetime | None,
) -> UserEntry | None:
"""Update user's premium subscription status.
Args:
user_id: The user's UUID.
is_premium: Whether user has premium.
premium_until: When premium expires, or None if not premium.
Returns:
Updated UserEntry, or None if user not found.
"""
...
async def delete(self, user_id: UUID) -> bool:
"""Delete a user account.
This will cascade delete all related data (decks, collection, etc.)
based on database constraints.
Args:
user_id: The user's UUID.
Returns:
True if deleted, False if not found.
"""
...
class LinkedAccountRepository(Protocol):
"""Protocol for OAuth linked accounts data access.
Users can link multiple OAuth providers to a single account.
The primary OAuth provider is stored on the User model itself;
additional linked providers are stored as LinkedAccount records.
"""
async def get_by_provider(
self,
provider: str,
oauth_id: str,
) -> LinkedAccountEntry | None:
"""Get a linked account by provider and OAuth ID.
Args:
provider: OAuth provider name (google, discord).
oauth_id: Unique ID from the OAuth provider.
Returns:
LinkedAccountEntry if found, None otherwise.
"""
...
async def get_by_user(self, user_id: UUID) -> list[LinkedAccountEntry]:
"""Get all linked accounts for a user.
Args:
user_id: The user's UUID.
Returns:
List of LinkedAccountEntry, ordered by provider.
"""
...
async def create(
self,
user_id: UUID,
provider: str,
oauth_id: str,
email: str | None = None,
display_name: str | None = None,
avatar_url: str | None = None,
) -> LinkedAccountEntry:
"""Link an OAuth provider to a user account.
Args:
user_id: The user's UUID.
provider: OAuth provider name.
oauth_id: Unique ID from the OAuth provider.
email: Email from the OAuth provider.
display_name: Display name from the OAuth provider.
avatar_url: Avatar URL from the OAuth provider.
Returns:
The created LinkedAccountEntry.
"""
...
async def delete(self, user_id: UUID, provider: str) -> bool:
"""Unlink an OAuth provider from a user account.
Args:
user_id: The user's UUID.
provider: OAuth provider name to unlink.
Returns:
True if deleted, False if not found.
"""
...