diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000..f0c2d16 --- /dev/null +++ b/src/database.py @@ -0,0 +1,46 @@ +"""SQLAlchemy-Engine, Session und Base für BSN-Livesystem.""" + +from __future__ import annotations + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker + + +class Base(DeclarativeBase): + """Base-Klasse für SQLAlchemy-Modelle.""" + + pass + + +def create_engine_connection() -> object: + """Erstellt die SQLAlchemy-Engine mit SQLite.""" + return create_engine( + "sqlite:////tmp/bsn_livesystem.db", + connect_args={"check_same_thread": False}, + ) + + +def create_session_factory(engine: object) -> type: + """Erzeugt eine Session-Fakton für die Engine.""" + return sessionmaker(bind=engine) + + +# Globale Instanzen +_engine = create_engine_connection() +_session_factory = create_session_factory(_engine) + + +def get_engine(): + return _engine + + +def get_session(): + return _session_factory() + + +def reset_database(engine=None) -> None: + """Löscht und erstellt alle Tabellen neu.""" + if engine is None: + engine = get_engine() + Base.metadata.drop_all(bind=engine) + Base.metadata.create_all(bind=engine) diff --git a/src/models/spiel.py b/src/models/spiel.py new file mode 100644 index 0000000..0e5e2c5 --- /dev/null +++ b/src/models/spiel.py @@ -0,0 +1,22 @@ +"""SQLAlchemy-Modell für Brettspiele.""" + +from __future__ import annotations + +from sqlalchemy import Float, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from src.database import Base + + +class SpielDB(Base): + """SQLAlchemy-Modell für ein Brettspiel.""" + + __tablename__ = "spiele" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(200), nullable=False) + min_spieler: Mapped[int] = mapped_column(Integer, default=1) + max_spieler: Mapped[int] = mapped_column(Integer, default=10) + dauer_min: Mapped[int] = mapped_column(Integer, default=30) + komplexitaet: Mapped[float] = mapped_column(Float, default=2.5) + kategorie: Mapped[str] = mapped_column(String(100), default="Familienspiel") diff --git a/src/services/game_service.py b/src/services/game_service.py index b0a1632..d54ae25 100644 --- a/src/services/game_service.py +++ b/src/services/game_service.py @@ -4,6 +4,9 @@ from __future__ import annotations from pydantic import BaseModel, Field +from src.database import Base, get_engine, get_session +from src.models.spiel import SpielDB + class Spiel(BaseModel): """Ein Brettspiel-Datenmodell.""" @@ -18,67 +21,62 @@ class Spiel(BaseModel): class GameService: - """Verwaltet die Spiele-Datenbank (In-Memory für jetzt, später DB).""" + """Verwaltet die Spiele-Datenbank (SQLAlchemy mit SQLite-Persistenz).""" def __init__(self) -> None: - self._spiele: dict[int, Spiel] = {} - self._next_id = 1 + engine = get_engine() + Base.metadata.create_all(bind=engine) + self._session = get_session() + + def _to_pydantic(self, spiel_db: SpielDB) -> Spiel: + """Konvertiert SpielDB nach Pydantic.""" + return Spiel( + name=spiel_db.name, + id=spiel_db.id, + min_spieler=spiel_db.min_spieler, + max_spieler=spiel_db.max_spieler, + dauer_min=spiel_db.dauer_min, + komplexitaet=spiel_db.komplexitaet, + kategorie=spiel_db.kategorie, + ) def hinzufuegen(self, spiel: Spiel) -> Spiel: - """Fügt ein Spiel hinzu und weist eine ID zu. - - Args: - spiel: Das hinzuzufügende Spiel. - - Returns: - Das zugewiesene Spiel mit ID. - """ - spiel.id = self._next_id - self._spiele[self._next_id] = spiel - self._next_id += 1 - return spiel + """Fügt ein Spiel hinzu und weist eine ID zu.""" + spiel_db = SpielDB( + name=spiel.name, + min_spieler=spiel.min_spieler, + max_spieler=spiel.max_spieler, + dauer_min=spiel.dauer_min, + komplexitaet=spiel.komplexitaet, + kategorie=spiel.kategorie, + ) + self._session.add(spiel_db) + self._session.commit() + return self._to_pydantic(spiel_db) def alle(self) -> list[Spiel]: - """Gibt alle Spiele zurück. - - Returns: - Liste aller Spiele. - """ - return list(self._spiele.values()) + """Gibt alle Spiele zurück.""" + return [self._to_pydantic(s) for s in self._session.query(SpielDB).all()] def suchen(self, name: str) -> list[Spiel]: - """Sucht Spiele nach Name (case-insensitive Teilstring). - - Args: - name: Der Suchbegriff. - - Returns: - Liste der gefundenen Spiele. - """ - name_lower = name.lower() - return [spiel for spiel in self._spiele.values() if name_lower in spiel.name.lower()] + """Sucht Spiele nach Name (case-insensitive).""" + spiele_db = self._session.query(SpielDB).filter( + SpielDB.name.ilike(f"%{name}%") + ).all() + return [self._to_pydantic(s) for s in spiele_db] def nach_id(self, spiel_id: int) -> Spiel | None: - """Holt ein Spiel anhand seiner ID. - - Args: - spiel_id: Die ID des Spiels. - - Returns: - Das gefundene Spiel oder None. - """ - return self._spiele.get(spiel_id) + """Holt ein Spiel anhand seiner ID.""" + spiel_db = self._session.get(SpielDB, spiel_id) + if spiel_db is None: + return None + return self._to_pydantic(spiel_db) def loesche(self, spiel_id: int) -> bool: - """Löscht ein Spiel anhand seiner ID. - - Args: - spiel_id: Die ID des zu löschenden Spiels. - - Returns: - True wenn gelöscht, False wenn nicht existiert. - """ - if spiel_id in self._spiele: - del self._spiele[spiel_id] - return True - return False + """Löscht ein Spiel anhand seiner ID.""" + spiel_db = self._session.get(SpielDB, spiel_id) + if spiel_db is None: + return False + self._session.delete(spiel_db) + self._session.commit() + return True diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6b42e7f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,44 @@ +"""Pytest-Konfiguration: Redis-Mock und Datenbank-Reset.""" + +from __future__ import annotations + +import sys +import unittest.mock as mock + +import fakeredis +import pytest +from src.database import Base, _engine + + +# --- Fake redis module that provides real-like exceptions --- + +import redis as _real_redis + +class _FakeRedis: + """Wraps fakeredis with the correct redis.exceptions hierarchy.""" + @staticmethod + def exceptions(): + return _real_redis.exceptions + + def __init__(self, *args, **kwargs): + self._conn = fakeredis.FakeStrictRedis(decode_responses=True) + + def __getattr__(self, name): + return getattr(self._conn, name) + + @property + def exceptions(self): + return _real_redis.exceptions + + +# Install BEFORE any app code runs +sys.modules["redis"] = mock.MagicMock() +sys.modules["redis"].Redis = _FakeRedis +sys.modules["redis"].exceptions = _real_redis.exceptions + + +@pytest.fixture(autouse=True) +def reset_db() -> None: + """Setzt die Datenbank vor jedem Test zurueck.""" + Base.metadata.drop_all(bind=_engine) + Base.metadata.create_all(bind=_engine) diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..41dd7ef --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,46 @@ +"""Tests für src.database — SQLAlchemy-Engine und Session.""" + +from __future__ import annotations + +from sqlalchemy import inspect + +from src.database import Base, get_engine, get_session +from src.models.spiel import SpielDB + + +class TestDatabase: + """Tests für die Datenbank-Schicht.""" + + def test_engine_is_available(self) -> None: + """engine ist eine SQLAlchemy-Engine.""" + engine = get_engine() + assert engine is not None + + def test_session_ist_ernzeugbar(self) -> None: + """session kann erstellt werden.""" + session = get_session() + assert session is not None + session.close() + + def test_base_metadata_contains_spiel(self) -> None: + """Base.metadata registriert spiele-Tabelle.""" + engine = get_engine() + inspector = inspect(engine) + metadata_tables = set(inspector.get_table_names()) + # Nach create_all in game_service sollte spiele existieren + assert "spiele" in metadata_tables, f"Keine spiele-Tabelle in {metadata_tables}" + + def test_sqlalchemy_spiel_modell_erstellt(self) -> None: + """SpielDB-Modell hat korrekte Spalten.""" + assert hasattr(SpielDB, "name") + assert hasattr(SpielDB, "min_spieler") + assert hasattr(SpielDB, "max_spieler") + assert hasattr(SpielDB, "dauer_min") + assert hasattr(SpielDB, "komplexitaet") + assert hasattr(SpielDB, "kategorie") + + def test_session_get_bind(self) -> None: + """session.get_bind() gibt engine zurück.""" + session = get_session() + assert session.get_bind() is not None + session.close()