diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..ff267dd --- /dev/null +++ b/app/models.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy import Integer, String, Text, Boolean, DateTime, ForeignKey +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker + + +class Base(DeclarativeBase): + pass + + +class Config(Base): + __tablename__ = "configs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String, nullable=False) + token: Mapped[str] = mapped_column(String, unique=True, nullable=False) + base_yaml: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow + ) + + subscriptions: Mapped[list["Subscription"]] = relationship( + back_populates="config", cascade="all, delete-orphan" + ) + export_logs: Mapped[list["ExportLog"]] = relationship( + back_populates="config", cascade="all, delete-orphan" + ) + + +class Subscription(Base): + __tablename__ = "subscriptions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False) + name: Mapped[str] = mapped_column(String, nullable=False) + url: Mapped[str] = mapped_column(String, nullable=False) + last_fetched_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + config: Mapped["Config"] = relationship(back_populates="subscriptions") + + +class ExportLog(Base): + __tablename__ = "export_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + config_id: Mapped[int] = mapped_column(Integer, ForeignKey("configs.id"), nullable=False) + fetched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + node_count: Mapped[int] = mapped_column(Integer, default=0) + success: Mapped[bool] = mapped_column(Boolean, default=True) + error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + config: Mapped["Config"] = relationship(back_populates="export_logs") + + +def make_engine(database_url: str): + return create_async_engine(database_url) + + +def make_session_factory(engine): + return async_sessionmaker(engine, expire_on_commit=False) diff --git a/app/requirements.txt b/app/requirements.txt index 73e8e7c..b512714 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,6 +1,6 @@ fastapi==0.115.5 uvicorn[standard]==0.32.1 -sqlalchemy[asyncio]==2.0.36 +sqlalchemy[asyncio]==2.0.49 aiosqlite==0.20.0 httpx==0.28.0 pyyaml==6.0.2 diff --git a/app/tests/conftest.py b/app/tests/conftest.py new file mode 100644 index 0000000..0112355 --- /dev/null +++ b/app/tests/conftest.py @@ -0,0 +1,18 @@ +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import pytest_asyncio +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker +from models import Base + + +@pytest_asyncio.fixture +async def db_session(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + Session = async_sessionmaker(engine, expire_on_commit=False) + async with Session() as session: + yield session + await engine.dispose() diff --git a/app/tests/test_models.py b/app/tests/test_models.py new file mode 100644 index 0000000..989750f --- /dev/null +++ b/app/tests/test_models.py @@ -0,0 +1,67 @@ +import uuid +import pytest +from sqlalchemy import select +from models import Config, Subscription, ExportLog + + +async def test_config_insert_and_retrieve(db_session): + token = str(uuid.uuid4()) + config = Config(name="my-config", token=token, base_yaml="proxies: []") + db_session.add(config) + await db_session.commit() + + result = await db_session.execute(select(Config).where(Config.token == token)) + found = result.scalar_one() + assert found.id is not None + assert found.name == "my-config" + assert found.created_at is not None + + +async def test_subscription_belongs_to_config(db_session): + config = Config(name="c", token=str(uuid.uuid4()), base_yaml="proxies: []") + db_session.add(config) + await db_session.flush() + + sub = Subscription(config_id=config.id, name="provider1", url="https://example.com/sub") + db_session.add(sub) + await db_session.commit() + + result = await db_session.execute(select(Subscription).where(Subscription.config_id == config.id)) + subs = result.scalars().all() + assert len(subs) == 1 + assert subs[0].name == "provider1" + + +async def test_cascade_delete_removes_subscriptions(db_session): + config = Config(name="c", token=str(uuid.uuid4()), base_yaml="proxies: []") + db_session.add(config) + await db_session.flush() + + sub = Subscription(config_id=config.id, name="p", url="https://example.com/sub") + log = ExportLog(config_id=config.id, node_count=5, success=True) + db_session.add(sub) + db_session.add(log) + await db_session.commit() + + sub_id = sub.id + log_id = log.id + + await db_session.delete(config) + await db_session.commit() + + assert (await db_session.get(Subscription, sub_id)) is None + assert (await db_session.get(ExportLog, log_id)) is None + + +async def test_export_log_defaults(db_session): + config = Config(name="c", token=str(uuid.uuid4()), base_yaml="proxies: []") + db_session.add(config) + await db_session.flush() + + log = ExportLog(config_id=config.id) + db_session.add(log) + await db_session.commit() + + assert log.success is True + assert log.node_count == 0 + assert log.fetched_at is not None