diff --git a/mobilizon_reshare/cli/main.py b/mobilizon_reshare/cli/main.py index 069bdc7..2231855 100644 --- a/mobilizon_reshare/cli/main.py +++ b/mobilizon_reshare/cli/main.py @@ -1,10 +1,11 @@ import logging.config - from mobilizon_reshare.event.event_selection_strategies import select_event_to_publish from mobilizon_reshare.mobilizon.events import get_unpublished_events from mobilizon_reshare.models.publication import PublicationStatus -from mobilizon_reshare.publishers import get_active_publishers +from mobilizon_reshare.publishers.coordinator import ( + PublicationFailureNotifiersCoordinator, +) from mobilizon_reshare.publishers.coordinator import PublisherCoordinator from mobilizon_reshare.storage.query import ( get_published_events, @@ -23,7 +24,9 @@ async def main(): :return: """ - active_publishers = get_active_publishers() + # TODO: the logic to get published and unpublished events is probably redundant. + # We need a simpler way to bring together events from mobilizon, unpublished events from the db + # and published events from the DB # Load past events published_events = list(await get_published_events()) @@ -31,27 +34,24 @@ async def main(): # Pull unpublished events from Mobilizon unpublished_events = get_unpublished_events(published_events) # Store in the DB only the ones we didn't know about - await create_unpublished_events(unpublished_events, active_publishers) + await create_unpublished_events(unpublished_events) event = select_event_to_publish( published_events, # We must load unpublished events from DB since it contains # merged state between Mobilizon and previous WAITING events. list(await get_db_unpublished_events()), ) - if event: - logger.debug(f"Event to publish found: {event.name}") - result = PublisherCoordinator( - event, - [ - (pub.id, pub.publisher.name) - for pub in await publications_with_status( - status=PublicationStatus.WAITING, - event_mobilizon_id=event.mobilizon_id, - ) - ], - ).run() - await save_publication_report(result, event) - return 0 if result.successful else 1 + if event: + + waiting_publications = await publications_with_status( + status=PublicationStatus.WAITING, event_mobilizon_id=event.mobilizon_id, + ) + logger.debug(f"Event to publish found: {event.name}") + report = PublisherCoordinator(event, waiting_publications).run() + await save_publication_report(report, waiting_publications) + PublicationFailureNotifiersCoordinator(event, report).notify_failures() + + return 0 if report.successful else 1 else: return 0 diff --git a/mobilizon_reshare/config/notifiers.py b/mobilizon_reshare/config/notifiers.py index 78e2f32..110191f 100644 --- a/mobilizon_reshare/config/notifiers.py +++ b/mobilizon_reshare/config/notifiers.py @@ -1,3 +1,5 @@ +from typing import Iterator + from dynaconf import Validator telegram_validators = [ @@ -18,7 +20,7 @@ notifier_name_to_validators = { notifier_names = notifier_name_to_validators.keys() -def get_active_notifiers(settings): +def get_active_notifiers(settings) -> Iterator[str]: return filter( lambda notifier_name: settings["notifier"][notifier_name]["active"], notifier_names, diff --git a/mobilizon_reshare/models/event.py b/mobilizon_reshare/models/event.py index d282b82..a2e2f4b 100644 --- a/mobilizon_reshare/models/event.py +++ b/mobilizon_reshare/models/event.py @@ -5,7 +5,7 @@ from tortoise.models import Model class Event(Model): id = fields.UUIDField(pk=True) name = fields.TextField() - description = fields.TextField() + description = fields.TextField(null=True) mobilizon_id = fields.TextField() mobilizon_link = fields.TextField() diff --git a/mobilizon_reshare/publishers/__init__.py b/mobilizon_reshare/publishers/__init__.py index 6f6e077..5a995a8 100644 --- a/mobilizon_reshare/publishers/__init__.py +++ b/mobilizon_reshare/publishers/__init__.py @@ -1,6 +1,11 @@ -from mobilizon_reshare.config.config import get_settings +import mobilizon_reshare.config.notifiers import mobilizon_reshare.config.publishers +from mobilizon_reshare.config.config import get_settings def get_active_publishers(): return mobilizon_reshare.config.publishers.get_active_publishers(get_settings()) + + +def get_active_notifiers(): + return mobilizon_reshare.config.notifiers.get_active_notifiers(get_settings()) diff --git a/mobilizon_reshare/publishers/abstract.py b/mobilizon_reshare/publishers/abstract.py index d6d29d1..2705771 100644 --- a/mobilizon_reshare/publishers/abstract.py +++ b/mobilizon_reshare/publishers/abstract.py @@ -4,6 +4,7 @@ from abc import ABC, abstractmethod from dynaconf.utils.boxing import DynaBox from jinja2 import Environment, FileSystemLoader, Template +from requests import Response from mobilizon_reshare.config.config import get_settings from mobilizon_reshare.event.event import MobilizonEvent @@ -29,9 +30,6 @@ class AbstractNotifier(ABC): # the second the name of its service (ie: 'facebook', 'telegram') _conf = tuple() - def __init__(self, message: str): - self.message = message - def __repr__(self): return type(self).__name__ @@ -56,6 +54,13 @@ class AbstractNotifier(ABC): f" (should be 2-tuple)" ) + @abstractmethod + def send(self, message): + """ + Sends a message to the target channel + """ + raise NotImplementedError + def _log_debug(self, msg, *args, **kwargs): self.__log(logging.DEBUG, msg, *args, **kwargs) @@ -94,7 +99,7 @@ class AbstractNotifier(ABC): raise NotImplementedError @abstractmethod - def post(self) -> None: + def publish(self) -> None: """ Publishes the actual post on social media. Should raise ``PublisherError`` (or one of its subclasses) if @@ -135,7 +140,7 @@ class AbstractPublisher(AbstractNotifier): def __init__(self, event: MobilizonEvent): self.event = event - super().__init__(message=self.get_message_from_event()) + super().__init__() def is_event_valid(self) -> bool: try: @@ -172,3 +177,18 @@ class AbstractPublisher(AbstractNotifier): """ template_path = self.conf.msg_template_path or self.default_template_path return JINJA_ENV.get_template(template_path) + + @abstractmethod + def _send(self, message) -> Response: + pass + + @abstractmethod + def _validate_response(self, response: Response) -> None: + pass + + def send(self, message): + res = self._send(message) + self._validate_response(res) + + def publish(self) -> None: + self.send(message=self.get_message_from_event()) diff --git a/mobilizon_reshare/publishers/coordinator.py b/mobilizon_reshare/publishers/coordinator.py index 5dc377b..b1caec8 100644 --- a/mobilizon_reshare/publishers/coordinator.py +++ b/mobilizon_reshare/publishers/coordinator.py @@ -1,22 +1,41 @@ +import logging from dataclasses import dataclass, field from uuid import UUID from mobilizon_reshare.event.event import MobilizonEvent +from mobilizon_reshare.models.publication import Publication from mobilizon_reshare.models.publication import PublicationStatus +from mobilizon_reshare.publishers import get_active_notifiers, get_active_publishers +from mobilizon_reshare.publishers.abstract import AbstractPublisher from mobilizon_reshare.publishers.exceptions import PublisherError from mobilizon_reshare.publishers.telegram import TelegramPublisher -KEY2CLS = {"telegram": TelegramPublisher} +logger = logging.getLogger(__name__) + + +class BuildPublisherMixin: + @staticmethod + def build_publishers( + event: MobilizonEvent, publisher_names + ) -> dict[str, AbstractPublisher]: + name_to_publisher_class = {"telegram": TelegramPublisher} + + return { + publisher_name: name_to_publisher_class[publisher_name](event) + for publisher_name in publisher_names + } @dataclass class PublicationReport: status: PublicationStatus reason: str + publication_id: UUID @dataclass class PublisherCoordinatorReport: + publishers: dict[UUID, AbstractPublisher] reports: dict[UUID, PublicationReport] = field(default_factory={}) @property @@ -25,47 +44,54 @@ class PublisherCoordinatorReport: r.status == PublicationStatus.COMPLETED for r in self.reports.values() ) - def __iter__(self): - return self.reports.items().__iter__() - -class PublisherCoordinator: - def __init__(self, event: MobilizonEvent, publications: list[tuple[UUID, str]]): - self.publications = tuple( - (publication_id, KEY2CLS[publisher_name](event)) - for publication_id, publisher_name in publications - ) +class PublisherCoordinator(BuildPublisherMixin): + def __init__(self, event: MobilizonEvent, publications: dict[UUID, Publication]): + publishers = self.build_publishers(event, get_active_publishers()) + self.publishers_by_publication_id = { + publication_id: publishers[publication.publisher.name] + for publication_id, publication in publications.items() + } def run(self) -> PublisherCoordinatorReport: errors = self._validate() if errors: - return PublisherCoordinatorReport(reports=errors) + return PublisherCoordinatorReport( + reports=errors, publishers=self.publishers_by_publication_id + ) return self._post() def _make_successful_report(self): return { publication_id: PublicationReport( - status=PublicationStatus.COMPLETED, reason="", + status=PublicationStatus.COMPLETED, + reason="", + publication_id=publication_id, ) - for publication_id, _ in self.publications + for publication_id in self.publishers_by_publication_id } def _post(self): failed_publishers_reports = {} - for publication_id, p in self.publications: + for publication_id, p in self.publishers_by_publication_id.items(): try: - p.post() + p.publish() except PublisherError as e: failed_publishers_reports[publication_id] = PublicationReport( - status=PublicationStatus.FAILED, reason=repr(e), + status=PublicationStatus.FAILED, + reason=str(e), + publication_id=publication_id, ) + reports = failed_publishers_reports or self._make_successful_report() - return PublisherCoordinatorReport(reports) + return PublisherCoordinatorReport( + publishers=self.publishers_by_publication_id, reports=reports + ) def _validate(self): errors: dict[UUID, PublicationReport] = {} - for publication_id, p in self.publications: + for publication_id, p in self.publishers_by_publication_id.items(): reason = [] if not p.are_credentials_valid(): reason.append("Invalid credentials") @@ -76,7 +102,45 @@ class PublisherCoordinator: if len(reason) > 0: errors[publication_id] = PublicationReport( - status=PublicationStatus.FAILED, reason=", ".join(reason) + status=PublicationStatus.FAILED, + reason=", ".join(reason), + publication_id=publication_id, ) return errors + + +class AbstractNotifiersCoordinator(BuildPublisherMixin): + def __init__(self, event: MobilizonEvent): + self.event = event + self.notifiers = self.build_publishers(event, get_active_notifiers()) + + def send_to_all(self, message): + # TODO: failure to notify should fail safely and write to a dedicated log + for notifier in self.notifiers.values(): + notifier.send(message) + + +class PublicationFailureNotifiersCoordinator(AbstractNotifiersCoordinator): + def __init__( + self, + event: MobilizonEvent, + publisher_coordinator_report: PublisherCoordinatorReport, + ): + self.report = publisher_coordinator_report + super(PublicationFailureNotifiersCoordinator, self).__init__(event) + + def build_failure_message(self, report: PublicationReport): + return ( + f"Publication {report.publication_id} failed with status: {report.status}.\n" + f"Reason: {report.reason}" + ) + + def notify_failures(self): + for publication_id, report in self.report.reports.items(): + + logger.info( + f"Sending failure notifications for publication: {publication_id}" + ) + if report.status == PublicationStatus.FAILED: + self.send_to_all(self.build_failure_message(report)) diff --git a/mobilizon_reshare/publishers/telegram.py b/mobilizon_reshare/publishers/telegram.py index edede42..8614a0e 100644 --- a/mobilizon_reshare/publishers/telegram.py +++ b/mobilizon_reshare/publishers/telegram.py @@ -1,14 +1,15 @@ import pkg_resources import requests +from requests import Response -from .abstract import AbstractPublisher -from .exceptions import ( +from mobilizon_reshare.formatting.description import html_to_markdown +from mobilizon_reshare.publishers.abstract import AbstractPublisher +from mobilizon_reshare.publishers.exceptions import ( InvalidBot, InvalidCredentials, InvalidEvent, InvalidResponse, ) -from ..formatting.description import html_to_markdown class TelegramPublisher(AbstractPublisher): @@ -21,18 +22,23 @@ class TelegramPublisher(AbstractPublisher): "mobilizon_reshare.publishers.templates", "telegram.tmpl.j2" ) - def post(self) -> None: - conf = self.conf - res = requests.post( - url=f"https://api.telegram.org/bot{conf.token}/sendMessage", + def _escape_message(self, message: str) -> str: + return ( + message.replace("-", "\\-") + .replace(".", "\\.") + .replace("(", "\\(") + .replace(")", "\\)") + ) + + def _send(self, message: str) -> Response: + return requests.post( + url=f"https://api.telegram.org/bot{self.conf.token}/sendMessage", json={ - "chat_id": conf.chat_id, - "text": self.message, + "chat_id": self.conf.chat_id, + "text": self._escape_message(message), "parse_mode": "markdownv2", }, ) - print(res.json()) - self._validate_response(res) def validate_credentials(self): conf = self.conf diff --git a/mobilizon_reshare/storage/query.py b/mobilizon_reshare/storage/query.py index 605f5bb..05d7274 100644 --- a/mobilizon_reshare/storage/query.py +++ b/mobilizon_reshare/storage/query.py @@ -1,9 +1,9 @@ import logging -import sys -from typing import Iterable, Optional, Union +from typing import Iterable, Optional, Union, Dict from uuid import UUID import arrow +import sys from arrow import Arrow from tortoise.queryset import QuerySet from tortoise.transactions import atomic @@ -12,6 +12,7 @@ from mobilizon_reshare.event.event import MobilizonEvent, EventPublicationStatus from mobilizon_reshare.models.event import Event from mobilizon_reshare.models.publication import Publication, PublicationStatus from mobilizon_reshare.models.publisher import Publisher +from mobilizon_reshare.publishers import get_active_publishers from mobilizon_reshare.publishers.coordinator import PublisherCoordinatorReport logger = logging.getLogger(__name__) @@ -52,7 +53,7 @@ async def publications_with_status( event_mobilizon_id: Optional[UUID] = None, from_date: Optional[Arrow] = None, to_date: Optional[Arrow] = None, -) -> Iterable[Publication]: +) -> Dict[UUID, Publication]: query = Publication.filter(status=status) if event_mobilizon_id: @@ -62,7 +63,10 @@ async def publications_with_status( query = _add_date_window(query, "timestamp", from_date, to_date) - return await query.prefetch_related("publisher").order_by("timestamp").distinct() + publications_list = ( + await query.prefetch_related("publisher").order_by("timestamp").distinct() + ) + return {pub.id: pub for pub in publications_list} async def events_with_status( @@ -102,7 +106,17 @@ async def get_all_events( async def get_published_events() -> Iterable[MobilizonEvent]: - return await events_with_status([EventPublicationStatus.COMPLETED]) + """ + Retrieves events that are not waiting. Function could be renamed to something more fitting + :return: + """ + return await events_with_status( + [ + EventPublicationStatus.COMPLETED, + EventPublicationStatus.PARTIAL, + EventPublicationStatus.FAILED, + ] + ) async def get_unpublished_events() -> Iterable[MobilizonEvent]: @@ -161,7 +175,6 @@ async def save_publication( @atomic(CONNECTION_NAME) async def create_unpublished_events( unpublished_mobilizon_events: Iterable[MobilizonEvent], - active_publishers: Iterable[str], ) -> None: # We store only new events, i.e. events whose mobilizon_id wasn't found in the DB. unpublished_event_models = set( @@ -176,7 +189,7 @@ async def create_unpublished_events( for event in unpublished_events: event_model = await save_event(event) - for publisher in active_publishers: + for publisher in get_active_publishers(): await save_publication( publisher, event_model, status=PublicationStatus.WAITING ) @@ -184,13 +197,10 @@ async def create_unpublished_events( @atomic(CONNECTION_NAME) async def save_publication_report( - coordinator_report: PublisherCoordinatorReport, event: MobilizonEvent + coordinator_report: PublisherCoordinatorReport, + publications: Dict[UUID, Publication], ) -> None: - publications: dict[UUID, Publication] = { - p.id: p for p in await get_mobilizon_event_publications(event) - } - - for publication_id, publication_report in coordinator_report: + for publication_id, publication_report in coordinator_report.reports.items(): publications[publication_id].status = publication_report.status publications[publication_id].reason = publication_report.reason diff --git a/tests/publishers/conftest.py b/tests/publishers/conftest.py new file mode 100644 index 0000000..06fa268 --- /dev/null +++ b/tests/publishers/conftest.py @@ -0,0 +1,89 @@ +from datetime import datetime, timedelta + +import pytest + +from mobilizon_reshare.event.event import MobilizonEvent +from mobilizon_reshare.publishers.abstract import AbstractPublisher +from mobilizon_reshare.publishers.exceptions import PublisherError, InvalidResponse + + +@pytest.fixture +def test_event(): + + now = datetime.now() + return MobilizonEvent( + **{ + "name": "TestName", + "description": "TestDescr", + "begin_datetime": now, + "end_datetime": now + timedelta(hours=1), + "mobilizon_link": "", + "mobilizon_id": "", + } + ) + + +@pytest.fixture +def mock_publisher_valid(event): + class MockPublisher(AbstractPublisher): + def validate_event(self) -> None: + pass + + def get_message_from_event(self) -> str: + return self.event.description + + def validate_credentials(self) -> None: + pass + + def validate_message(self) -> None: + pass + + def _send(self, message): + pass + + def _validate_response(self, response) -> None: + pass + + return MockPublisher(event) + + +@pytest.fixture +def mock_publisher_invalid(event): + class MockPublisher(AbstractPublisher): + def validate_event(self) -> None: + raise PublisherError("Invalid event") + + def get_message_from_event(self) -> str: + return "" + + def validate_credentials(self) -> None: + raise PublisherError("Invalid credentials") + + def validate_message(self) -> None: + raise PublisherError("Invalid message") + + def _send(self, message): + pass + + def _validate_response(self, response) -> None: + pass + + return MockPublisher(event) + + +@pytest.fixture +def mock_publisher_invalid_response(mock_publisher_invalid, event): + class MockPublisher(type(mock_publisher_invalid)): + def validate_event(self) -> None: + pass + + def validate_credentials(self) -> None: + pass + + def validate_message(self) -> None: + pass + + def _validate_response(self, response) -> None: + raise InvalidResponse("Invalid response") + + return MockPublisher(event) diff --git a/tests/publishers/test_abstract_predicates.py b/tests/publishers/test_abstract_predicates.py index 451a20c..40899f3 100644 --- a/tests/publishers/test_abstract_predicates.py +++ b/tests/publishers/test_abstract_predicates.py @@ -1,89 +1,22 @@ -import pytest - -from datetime import datetime, timedelta - -from mobilizon_reshare.event.event import MobilizonEvent -from mobilizon_reshare.publishers.abstract import AbstractPublisher -from mobilizon_reshare.publishers.exceptions import PublisherError +def test_are_credentials_valid(test_event, mock_publisher_valid): + assert mock_publisher_valid.are_credentials_valid() -@pytest.fixture -def test_event(): - class TestMobilizonEvent(MobilizonEvent): - pass - - now = datetime.now() - return TestMobilizonEvent( - **{ - "name": "TestName", - "description": "TestDescr", - "begin_datetime": now, - "end_datetime": now + timedelta(hours=1), - "mobilizon_link": "", - "mobilizon_id": "", - } - ) +def test_are_credentials_valid_false(mock_publisher_invalid): + assert not mock_publisher_invalid.are_credentials_valid() -def mock_publisher_valid(event): - class MockPublisher(AbstractPublisher): - def validate_event(self) -> None: - pass - - def get_message_from_event(self) -> str: - return self.event.description - - def validate_credentials(self) -> None: - pass - - def post(self) -> bool: - return True - - def validate_message(self) -> None: - pass - - return MockPublisher(event) +def test_is_event_valid(mock_publisher_valid): + assert mock_publisher_valid.is_event_valid() -def mock_publisher_invalid(event): - class MockPublisher(AbstractPublisher): - def validate_event(self) -> None: - raise PublisherError("Invalid event") - - def get_message_from_event(self) -> str: - return "" - - def validate_credentials(self) -> None: - raise PublisherError("Invalid credentials") - - def post(self) -> bool: - return False - - def validate_message(self) -> None: - raise PublisherError("Invalid message") - - return MockPublisher(event) +def test_is_event_valid_false(mock_publisher_invalid): + assert not mock_publisher_invalid.is_event_valid() -def test_are_credentials_valid(test_event): - assert mock_publisher_valid(test_event).are_credentials_valid() +def test_is_message_valid(mock_publisher_valid): + assert mock_publisher_valid.is_message_valid() -def test_are_credentials_valid_false(test_event): - assert not mock_publisher_invalid(test_event).are_credentials_valid() - - -def test_is_event_valid(test_event): - assert mock_publisher_valid(test_event).is_event_valid() - - -def test_is_event_valid_false(test_event): - assert not mock_publisher_invalid(test_event).is_event_valid() - - -def test_is_message_valid(test_event): - assert mock_publisher_valid(test_event).is_message_valid() - - -def test_is_message_valid_false(test_event): - assert not mock_publisher_invalid(test_event).is_message_valid() +def test_is_message_valid_false(mock_publisher_invalid): + assert not mock_publisher_invalid.is_message_valid() diff --git a/tests/publishers/test_coordinator.py b/tests/publishers/test_coordinator.py new file mode 100644 index 0000000..e2f8576 --- /dev/null +++ b/tests/publishers/test_coordinator.py @@ -0,0 +1,136 @@ +from uuid import UUID + +import pytest +from asynctest import MagicMock + +from mobilizon_reshare.event.event import MobilizonEvent +from mobilizon_reshare.models.publication import PublicationStatus, Publication +from mobilizon_reshare.models.publisher import Publisher +from mobilizon_reshare.publishers.coordinator import ( + PublisherCoordinatorReport, + PublicationReport, + PublisherCoordinator, + PublicationFailureNotifiersCoordinator, +) + + +@pytest.mark.parametrize( + "statuses, successful", + [ + [[PublicationStatus.COMPLETED, PublicationStatus.COMPLETED], True], + [[PublicationStatus.WAITING, PublicationStatus.COMPLETED], False], + [[PublicationStatus.COMPLETED, PublicationStatus.FAILED], False], + [[], True], + [[PublicationStatus.COMPLETED], True], + ], +) +def test_publication_report_successful(statuses, successful): + reports = {} + for i, status in enumerate(statuses): + reports[UUID(int=i)] = PublicationReport( + reason=None, publication_id=None, status=status + ) + assert PublisherCoordinatorReport(None, reports).successful == successful + + +@pytest.fixture +@pytest.mark.asyncio +async def mock_publication(test_event: MobilizonEvent,): + event = test_event.to_model() + await event.save() + publisher = Publisher(name="telegram") + await publisher.save() + publication = Publication( + id=UUID(int=1), + status=PublicationStatus.WAITING, + event=event, + publisher=publisher, + timestamp=None, + reason=None, + ) + await publication.save() + return publication + + +@pytest.mark.asyncio +async def test_coordinator_run_success( + test_event, mock_publication, mock_publisher_valid +): + coordinator = PublisherCoordinator( + test_event, {UUID(int=1): mock_publication, UUID(int=2): mock_publication} + ) + coordinator.publishers_by_publication_id = { + UUID(int=1): mock_publisher_valid, + UUID(int=2): mock_publisher_valid, + } + + report = coordinator.run() + assert len(report.reports) == 2 + assert report.successful, "\n".join( + map(lambda rep: rep.reason, report.reports.values()) + ) + + +@pytest.mark.asyncio +async def test_coordinator_run_failure( + test_event, mock_publication, mock_publisher_invalid +): + coordinator = PublisherCoordinator(test_event, {UUID(int=1): mock_publication}) + coordinator.publishers_by_publication_id = { + UUID(int=1): mock_publisher_invalid, + } + + report = coordinator.run() + assert len(report.reports) == 1 + assert not report.successful + assert ( + list(report.reports.values())[0].reason + == "Invalid credentials, Invalid event, Invalid message" + ) + + +@pytest.mark.asyncio +async def test_coordinator_run_failure_response( + test_event, mock_publication, mock_publisher_invalid_response +): + coordinator = PublisherCoordinator(test_event, {UUID(int=1): mock_publication}) + coordinator.publishers_by_publication_id = { + UUID(int=1): mock_publisher_invalid_response, + } + + report = coordinator.run() + assert len(report.reports) == 1 + assert not report.successful + assert list(report.reports.values())[0].reason == "Invalid response" + + +@pytest.mark.asyncio +async def test_notifier_coordinator_publication_failed( + test_event, mock_publisher_valid +): + mock_send = MagicMock() + mock_publisher_valid._send = mock_send + report = PublisherCoordinatorReport( + {UUID(int=1): mock_publisher_valid, UUID(int=2): mock_publisher_valid}, + { + UUID(int=1): PublicationReport( + status=PublicationStatus.FAILED, + reason="some failure", + publication_id=UUID(int=1), + ), + UUID(int=2): PublicationReport( + status=PublicationStatus.FAILED, + reason="some failure", + publication_id=UUID(int=2), + ), + }, + ) + coordinator = PublicationFailureNotifiersCoordinator(test_event, report) + coordinator.notifiers = { + UUID(int=1): mock_publisher_valid, + UUID(int=2): mock_publisher_valid, + } + coordinator.notify_failures() + + # 4 = 2 reports * 2 notifiers + assert mock_send.call_count == 4 diff --git a/tests/storage/__init__.py b/tests/storage/__init__.py index 3119ffb..e261f7e 100644 --- a/tests/storage/__init__.py +++ b/tests/storage/__init__.py @@ -1,9 +1,8 @@ from datetime import datetime, timezone, timedelta - from uuid import UUID -from mobilizon_reshare.models.publication import PublicationStatus from mobilizon_reshare.models.publication import Publication +from mobilizon_reshare.models.publication import PublicationStatus today = datetime( year=2021, month=6, day=6, hour=5, minute=0, tzinfo=timezone(timedelta(hours=2)), @@ -26,6 +25,7 @@ complete_specification = { {"event_idx": 3, "publisher_idx": 1, "status": PublicationStatus.WAITING}, {"event_idx": 3, "publisher_idx": 2, "status": PublicationStatus.WAITING}, ], + "publisher": ["telegram", "twitter", "mastodon"], } diff --git a/tests/storage/conftest.py b/tests/storage/conftest.py index 6f32dd9..214acbe 100644 --- a/tests/storage/conftest.py +++ b/tests/storage/conftest.py @@ -13,14 +13,13 @@ from tests.storage import today async def _generate_publishers(specification): publishers = [] - for i in range( - specification["publisher"] if "publisher" in specification.keys() else 3 - ): + for i, publisher_name in enumerate(specification["publisher"]): publisher = Publisher( - id=UUID(int=i), name=f"publisher_{i}", account_ref=f"account_ref_{i}" + id=UUID(int=i), name=publisher_name, account_ref=f"account_ref_{i}" ) publishers.append(publisher) await publisher.save() + return publishers diff --git a/tests/storage/test_query.py b/tests/storage/test_query.py index dccf8ff..f26c40b 100644 --- a/tests/storage/test_query.py +++ b/tests/storage/test_query.py @@ -16,8 +16,8 @@ from mobilizon_reshare.storage.query import ( get_publishers, publications_with_status, ) -from tests.storage import result_publication from tests.storage import complete_specification +from tests.storage import result_publication from tests.storage import today event_0 = MobilizonEvent( @@ -28,9 +28,9 @@ event_0 = MobilizonEvent( thumbnail_link="thumblink_0", location="loc_0", publication_time={ - "publisher_0": arrow.get(today + timedelta(hours=0)), - "publisher_1": arrow.get(today + timedelta(hours=1)), - "publisher_2": arrow.get(today + timedelta(hours=2)), + "telegram": arrow.get(today + timedelta(hours=0)), + "twitter": arrow.get(today + timedelta(hours=1)), + "mastodon": arrow.get(today + timedelta(hours=2)), }, status=EventPublicationStatus.COMPLETED, begin_datetime=arrow.get(today + timedelta(days=0)), @@ -43,8 +43,7 @@ async def test_get_published_events(generate_models): await generate_models(complete_specification) published_events = list(await get_published_events()) - assert len(published_events) == 1 - assert published_events == [event_0] + assert len(published_events) == 3 @pytest.mark.asyncio @@ -123,7 +122,6 @@ async def test_create_unpublished_events( expected_result, generate_models, event_generator, ): await generate_models(complete_specification) - event_3 = event_generator(begin_date=arrow.get(today + timedelta(days=6))) event_4 = event_generator( begin_date=arrow.get(today + timedelta(days=12)), mobilizon_id="67890" @@ -132,10 +130,7 @@ async def test_create_unpublished_events( events_from_internet = [MobilizonEvent.from_model(models[0]), event_3, event_4] - await create_unpublished_events( - unpublished_mobilizon_events=events_from_internet, - active_publishers=["publisher_0", "publisher_1", "publisher_2"], - ) + await create_unpublished_events(unpublished_mobilizon_events=events_from_internet,) unpublished_events = list(await get_unpublished_events()) assert len(unpublished_events) == 4 @@ -156,25 +151,22 @@ async def test_get_mobilizon_event_publications(generate_models): assert len(publications) == 3 assert publications[0].event.name == "event_0" - assert publications[0].publisher.name == "publisher_0" + assert publications[0].publisher.name == "telegram" assert publications[0].status == PublicationStatus.COMPLETED assert publications[1].event.name == "event_0" - assert publications[1].publisher.name == "publisher_1" + assert publications[1].publisher.name == "twitter" assert publications[1].status == PublicationStatus.COMPLETED assert publications[2].event.name == "event_0" - assert publications[2].publisher.name == "publisher_2" + assert publications[2].publisher.name == "mastodon" assert publications[2].status == PublicationStatus.COMPLETED @pytest.mark.asyncio @pytest.mark.parametrize( "name,expected_result", - [ - [None, {"publisher_0", "publisher_1", "publisher_2"}], - ["publisher_0", {"publisher_0"}], - ], + [[None, {"telegram", "twitter", "mastodon"}], ["telegram", {"telegram"}]], ) async def test_get_publishers( name, expected_result, generate_models, @@ -250,7 +242,7 @@ async def test_publications_with_status( to_date=to_date, ) - assert publications == expected_result + assert publications == {pub.id: pub for pub in expected_result} @pytest.mark.asyncio diff --git a/tests/storage/test_update.py b/tests/storage/test_update.py index f9edef9..194df61 100644 --- a/tests/storage/test_update.py +++ b/tests/storage/test_update.py @@ -1,6 +1,6 @@ from datetime import timedelta from uuid import UUID -from tests.storage import complete_specification + import arrow import pytest @@ -16,9 +16,11 @@ from mobilizon_reshare.storage.query import ( update_publishers, save_publication_report, ) +from mobilizon_reshare.storage.query import publications_with_status +from tests.storage import complete_specification from tests.storage import today -two_publishers_specification = {"publisher": 2} +two_publishers_specification = {"publisher": ["telegram", "twitter"]} @pytest.mark.asyncio @@ -27,21 +29,21 @@ two_publishers_specification = {"publisher": 2} [ [ two_publishers_specification, - ["publisher_0", "publisher_1"], + ["telegram", "twitter"], { - Publisher(id=UUID(int=0), name="publisher_0"), - Publisher(id=UUID(int=1), name="publisher_1"), + Publisher(id=UUID(int=0), name="telegram"), + Publisher(id=UUID(int=1), name="twitter"), }, ], [ - {"publisher": 0}, - ["publisher_0", "publisher_1"], - {"publisher_0", "publisher_1"}, + {"publisher": ["telegram"]}, + ["telegram", "twitter"], + {"telegram", "twitter"}, ], [ two_publishers_specification, - ["publisher_0", "publisher_2", "publisher_3"], - {"publisher_0", "publisher_1", "publisher_2", "publisher_3"}, + ["telegram", "mastodon", "facebook"], + {"telegram", "twitter", "mastodon", "facebook"}, ], ], ) @@ -68,12 +70,17 @@ async def test_update_publishers( PublisherCoordinatorReport( reports={ UUID(int=3): PublicationReport( - status=PublicationStatus.FAILED, reason="Invalid credentials" + status=PublicationStatus.FAILED, + reason="Invalid credentials", + publication_id=UUID(int=3), ), UUID(int=4): PublicationReport( - status=PublicationStatus.COMPLETED, reason="" + status=PublicationStatus.COMPLETED, + reason="", + publication_id=UUID(int=4), ), - } + }, + publishers={}, ), MobilizonEvent( name="event_1", @@ -103,7 +110,11 @@ async def test_save_publication_report( specification, report, event, expected_result, generate_models, ): await generate_models(specification) - await save_publication_report(report, event) + + publications = await publications_with_status( + status=PublicationStatus.WAITING, event_mobilizon_id=event.mobilizon_id, + ) + await save_publication_report(report, publications) publication_ids = set(report.reports.keys()) publications = { p_id: await Publication.filter(id=p_id).first() for p_id in publication_ids