diff --git a/backend/app/services/source_service.py b/backend/app/services/source_service.py new file mode 100644 index 0000000..62d88ac --- /dev/null +++ b/backend/app/services/source_service.py @@ -0,0 +1,267 @@ +"""FC-3a: CRUD over Source rows, with platform/url/config validation and +is_subscription auto-flip on first add / last delete. +""" + +from dataclasses import dataclass + +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import Artist, Source + + +# App-level allowlist (frozenset to make accidental mutation loud). +KNOWN_PLATFORMS: frozenset[str] = frozenset({ + "patreon", "fanbox", "subscribestar", "pixiv", "deviantart", +}) + + +# --- Errors ----------------------------------------------------------------- + +class SourceServiceError(Exception): + """Base.""" + + +class ArtistNotFoundError(SourceServiceError): + pass + + +class UnknownPlatformError(SourceServiceError): + def __init__(self, platform: str): + super().__init__(f"unknown platform: {platform!r}") + self.platform = platform + + +class InvalidConfigError(SourceServiceError): + pass + + +class EmptyUrlError(SourceServiceError): + pass + + +class DuplicateSourceError(SourceServiceError): + def __init__(self, existing_id: int): + super().__init__(f"duplicate source (existing id={existing_id})") + self.existing_id = existing_id + + +@dataclass(frozen=True) +class SourceRecord: + id: int + artist_id: int + artist_name: str + artist_slug: str + platform: str + url: str + enabled: bool + config_overrides: dict | None + last_checked_at: str | None + last_error: str | None + check_interval_override: int | None + + def to_dict(self) -> dict: + return { + "id": self.id, + "artist_id": self.artist_id, + "artist_name": self.artist_name, + "artist_slug": self.artist_slug, + "platform": self.platform, + "url": self.url, + "enabled": self.enabled, + "config_overrides": self.config_overrides, + "last_checked_at": self.last_checked_at, + "last_error": self.last_error, + "check_interval_override": self.check_interval_override, + } + + +# --- Service ---------------------------------------------------------------- + +_EDITABLE = {"enabled", "url", "config_overrides", "check_interval_override", "platform"} + + +class SourceService: + def __init__(self, session: AsyncSession): + self.session = session + + async def _artist_or_raise(self, artist_id: int) -> Artist: + row = (await self.session.execute( + select(Artist).where(Artist.id == artist_id) + )).scalar_one_or_none() + if row is None: + raise ArtistNotFoundError(f"artist id={artist_id} not found") + return row + + @staticmethod + def _validate_platform(platform: str) -> str: + if platform not in KNOWN_PLATFORMS: + raise UnknownPlatformError(platform) + return platform + + @staticmethod + def _validate_url(url: str) -> str: + cleaned = (url or "").strip() + if not cleaned: + raise EmptyUrlError("url must not be empty") + return cleaned + + @staticmethod + def _validate_config(config: object) -> dict | None: + if config is None: + return None + if not isinstance(config, dict): + raise InvalidConfigError("config_overrides must be a JSON object") + return config + + async def _row_to_record(self, source: Source) -> SourceRecord: + artist = (await self.session.execute( + select(Artist.name, Artist.slug).where(Artist.id == source.artist_id) + )).one() + return SourceRecord( + id=source.id, + artist_id=source.artist_id, + artist_name=artist.name, + artist_slug=artist.slug, + platform=source.platform, + url=source.url, + enabled=source.enabled, + config_overrides=source.config_overrides, + last_checked_at=source.last_checked_at.isoformat() if source.last_checked_at else None, + last_error=source.last_error, + check_interval_override=source.check_interval_override, + ) + + async def list(self, artist_id: int | None = None) -> list[SourceRecord]: + stmt = ( + select(Source, Artist.name, Artist.slug) + .join(Artist, Artist.id == Source.artist_id) + .order_by(Artist.name.asc(), Source.id.asc()) + ) + if artist_id is not None: + stmt = stmt.where(Source.artist_id == artist_id) + rows = (await self.session.execute(stmt)).all() + return [ + SourceRecord( + id=s.id, artist_id=s.artist_id, + artist_name=name, artist_slug=slug, + platform=s.platform, url=s.url, enabled=s.enabled, + config_overrides=s.config_overrides, + last_checked_at=s.last_checked_at.isoformat() if s.last_checked_at else None, + last_error=s.last_error, + check_interval_override=s.check_interval_override, + ) + for s, name, slug in rows + ] + + async def get(self, source_id: int) -> SourceRecord | None: + source = (await self.session.execute( + select(Source).where(Source.id == source_id) + )).scalar_one_or_none() + if source is None: + return None + return await self._row_to_record(source) + + async def create( + self, + *, + artist_id: int, + platform: str, + url: str, + enabled: bool = True, + config_overrides: dict | None = None, + check_interval_override: int | None = None, + ) -> SourceRecord: + await self._artist_or_raise(artist_id) + platform = self._validate_platform(platform) + url = self._validate_url(url) + config_overrides = self._validate_config(config_overrides) + + prior_count = (await self.session.execute( + select(func.count(Source.id)).where(Source.artist_id == artist_id) + )).scalar_one() + + source = Source( + artist_id=artist_id, platform=platform, url=url, + enabled=enabled, config_overrides=config_overrides, + check_interval_override=check_interval_override, + ) + self.session.add(source) + try: + await self.session.flush() + except IntegrityError as exc: + await self.session.rollback() + existing = (await self.session.execute( + select(Source.id).where( + Source.artist_id == artist_id, + Source.platform == platform, + Source.url == url, + ) + )).scalar_one() + raise DuplicateSourceError(existing_id=existing) from exc + + if prior_count == 0: + await self.session.execute( + Artist.__table__.update() + .where(Artist.id == artist_id) + .values(is_subscription=True) + ) + await self.session.commit() + return await self._row_to_record(source) + + async def update(self, source_id: int, **fields) -> SourceRecord: + source = (await self.session.execute( + select(Source).where(Source.id == source_id) + )).scalar_one_or_none() + if source is None: + raise LookupError(f"source id={source_id} not found") + + for key in list(fields.keys()): + if key not in _EDITABLE: + fields.pop(key) + if "platform" in fields: + fields["platform"] = self._validate_platform(fields["platform"]) + if "url" in fields: + fields["url"] = self._validate_url(fields["url"]) + if "config_overrides" in fields: + fields["config_overrides"] = self._validate_config(fields["config_overrides"]) + + for key, value in fields.items(): + setattr(source, key, value) + try: + await self.session.flush() + except IntegrityError as exc: + await self.session.rollback() + existing = (await self.session.execute( + select(Source.id).where( + Source.artist_id == source.artist_id, + Source.platform == source.platform, + Source.url == source.url, + Source.id != source.id, + ) + )).scalar_one() + raise DuplicateSourceError(existing_id=existing) from exc + await self.session.commit() + return await self._row_to_record(source) + + async def delete(self, source_id: int) -> None: + source = (await self.session.execute( + select(Source).where(Source.id == source_id) + )).scalar_one_or_none() + if source is None: + raise LookupError(f"source id={source_id} not found") + artist_id = source.artist_id + await self.session.delete(source) + await self.session.flush() + + remaining = (await self.session.execute( + select(func.count(Source.id)).where(Source.artist_id == artist_id) + )).scalar_one() + if remaining == 0: + await self.session.execute( + Artist.__table__.update() + .where(Artist.id == artist_id) + .values(is_subscription=False) + ) + await self.session.commit() diff --git a/tests/test_source_service.py b/tests/test_source_service.py new file mode 100644 index 0000000..72e070f --- /dev/null +++ b/tests/test_source_service.py @@ -0,0 +1,131 @@ +import pytest +from sqlalchemy import select + +from backend.app.models import Artist +from backend.app.services.source_service import ( + ArtistNotFoundError, + DuplicateSourceError, + EmptyUrlError, + InvalidConfigError, + KNOWN_PLATFORMS, + SourceService, + UnknownPlatformError, +) + +pytestmark = pytest.mark.integration + + +async def _artist(db, name="Alice"): + a = Artist(name=name, slug=name.lower()) + db.add(a) + await db.flush() + return a + + +@pytest.mark.asyncio +async def test_known_platforms_contains_starter_set(db): + assert {"patreon", "fanbox", "subscribestar", "pixiv", "deviantart"} <= KNOWN_PLATFORMS + + +@pytest.mark.asyncio +async def test_create_flips_is_subscription_on_first_source(db): + artist = await _artist(db) + svc = SourceService(db) + rec = await svc.create( + artist_id=artist.id, platform="patreon", url="https://patreon.com/alice", + ) + assert rec.id is not None + is_sub = (await db.execute( + select(Artist.is_subscription).where(Artist.id == artist.id) + )).scalar_one() + assert is_sub is True + + +@pytest.mark.asyncio +async def test_delete_last_source_flips_is_subscription_off(db): + artist = await _artist(db) + svc = SourceService(db) + rec = await svc.create( + artist_id=artist.id, platform="patreon", url="https://patreon.com/alice", + ) + await svc.delete(rec.id) + is_sub = (await db.execute( + select(Artist.is_subscription).where(Artist.id == artist.id) + )).scalar_one() + assert is_sub is False + + +@pytest.mark.asyncio +async def test_create_rejects_unknown_platform(db): + artist = await _artist(db) + svc = SourceService(db) + with pytest.raises(UnknownPlatformError): + await svc.create( + artist_id=artist.id, platform="myspace", url="https://m/x", + ) + + +@pytest.mark.asyncio +async def test_create_rejects_non_dict_config(db): + artist = await _artist(db) + svc = SourceService(db) + with pytest.raises(InvalidConfigError): + await svc.create( + artist_id=artist.id, platform="patreon", + url="https://patreon.com/alice", config_overrides=[1, 2, 3], + ) + + +@pytest.mark.asyncio +async def test_create_rejects_empty_url(db): + artist = await _artist(db) + svc = SourceService(db) + with pytest.raises(EmptyUrlError): + await svc.create(artist_id=artist.id, platform="patreon", url=" ") + + +@pytest.mark.asyncio +async def test_create_rejects_unknown_artist(db): + svc = SourceService(db) + with pytest.raises(ArtistNotFoundError): + await svc.create(artist_id=99999, platform="patreon", url="https://x/y") + + +@pytest.mark.asyncio +async def test_create_duplicate_raises_with_existing_id(db): + artist = await _artist(db) + svc = SourceService(db) + first = await svc.create( + artist_id=artist.id, platform="patreon", url="https://patreon.com/alice", + ) + with pytest.raises(DuplicateSourceError) as exc: + await svc.create( + artist_id=artist.id, platform="patreon", + url="https://patreon.com/alice", + ) + assert exc.value.existing_id == first.id + + +@pytest.mark.asyncio +async def test_list_filters_by_artist(db): + a = await _artist(db, "Alice") + b = await _artist(db, "Bob") + svc = SourceService(db) + await svc.create(artist_id=a.id, platform="patreon", url="https://patreon.com/a") + await svc.create(artist_id=b.id, platform="patreon", url="https://patreon.com/b") + only_a = await svc.list(artist_id=a.id) + assert [s.artist_id for s in only_a] == [a.id] + all_rows = await svc.list() + assert len(all_rows) == 2 + + +@pytest.mark.asyncio +async def test_update_changes_fields(db): + artist = await _artist(db) + svc = SourceService(db) + rec = await svc.create( + artist_id=artist.id, platform="patreon", url="https://patreon.com/a", + ) + updated = await svc.update(rec.id, enabled=False, config_overrides={"videos": False}) + assert updated.enabled is False + assert updated.config_overrides == {"videos": False}