diff --git a/mobilizon_reshare/dataclasses/__init__.py b/mobilizon_reshare/dataclasses/__init__.py index 57b9974..d541d65 100644 --- a/mobilizon_reshare/dataclasses/__init__.py +++ b/mobilizon_reshare/dataclasses/__init__.py @@ -2,8 +2,12 @@ from mobilizon_reshare.dataclasses.event import _MobilizonEvent from mobilizon_reshare.dataclasses.event_publication_status import ( _EventPublicationStatus, ) -from mobilizon_reshare.dataclasses.publication import _EventPublication +from mobilizon_reshare.dataclasses.publication import ( + _EventPublication, + _PublicationNotification, +) EventPublication = _EventPublication MobilizonEvent = _MobilizonEvent EventPublicationStatus = _EventPublicationStatus +PublicationNotification = _PublicationNotification \ No newline at end of file diff --git a/mobilizon_reshare/dataclasses/publication.py b/mobilizon_reshare/dataclasses/publication.py index 480a167..a81ae1a 100644 --- a/mobilizon_reshare/dataclasses/publication.py +++ b/mobilizon_reshare/dataclasses/publication.py @@ -54,6 +54,11 @@ class RecapPublication(BasePublication): events: List[_MobilizonEvent] +@dataclass +class _PublicationNotification(BasePublication): + publication: _EventPublication + + @atomic() async def build_publications_for_event( event: _MobilizonEvent, publishers: Iterator[str] diff --git a/mobilizon_reshare/main/publish.py b/mobilizon_reshare/main/publish.py index ea318ec..c2f2cdd 100644 --- a/mobilizon_reshare/main/publish.py +++ b/mobilizon_reshare/main/publish.py @@ -23,7 +23,10 @@ from mobilizon_reshare.publishers.coordinators.event_publishing.publish import ( PublisherCoordinatorReport, PublisherCoordinator, ) -from mobilizon_reshare.storage.query.write import save_publication_report +from mobilizon_reshare.storage.query.write import ( + save_publication_report, + save_notification_report, +) logger = logging.getLogger(__name__) @@ -31,14 +34,16 @@ logger = logging.getLogger(__name__) async def publish_publications( publications: list[_EventPublication], ) -> PublisherCoordinatorReport: - report = PublisherCoordinator(publications).run() + publishers_report = PublisherCoordinator(publications).run() + await save_publication_report(publishers_report) - await save_publication_report(report) - for publication_report in report.reports: + for publication_report in publishers_report.reports: if not publication_report.successful: - PublicationFailureNotifiersCoordinator(publication_report,).notify_failure() + notifiers_report = PublicationFailureNotifiersCoordinator(publication_report,).notify_failure() + if notifiers_report: + await save_notification_report(notifiers_report) - return report + return publishers_report def perform_dry_run(publications: list[_EventPublication]): diff --git a/mobilizon_reshare/models/notification.py b/mobilizon_reshare/models/notification.py index c3e1e1c..8ceff34 100644 --- a/mobilizon_reshare/models/notification.py +++ b/mobilizon_reshare/models/notification.py @@ -5,10 +5,8 @@ from tortoise.models import Model class NotificationStatus(IntEnum): - WAITING = 1 - FAILED = 2 - PARTIAL = 3 - COMPLETED = 4 + FAILED = 0 + COMPLETED = 1 class Notification(Model): diff --git a/mobilizon_reshare/publishers/coordinators/__init__.py b/mobilizon_reshare/publishers/coordinators/__init__.py index ee54ee6..834fc45 100644 --- a/mobilizon_reshare/publishers/coordinators/__init__.py +++ b/mobilizon_reshare/publishers/coordinators/__init__.py @@ -16,7 +16,7 @@ class BasePublicationReport: def get_failure_message(self): return ( - f"Publication failed with status: {self.status}.\n" f"Reason: {self.reason}" + f"Publication failed with status: {self.status.name}.\n" f"Reason: {self.reason}" ) @@ -26,7 +26,7 @@ class BaseCoordinatorReport: @property def successful(self): - return all(r.status == PublicationStatus.COMPLETED for r in self.reports) + return all(r.successful for r in self.reports) logger = logging.getLogger(__name__) diff --git a/mobilizon_reshare/publishers/coordinators/event_publishing/__init__.py b/mobilizon_reshare/publishers/coordinators/event_publishing/__init__.py index 454c323..3498b62 100644 --- a/mobilizon_reshare/publishers/coordinators/event_publishing/__init__.py +++ b/mobilizon_reshare/publishers/coordinators/event_publishing/__init__.py @@ -20,7 +20,7 @@ class EventPublicationReport(BasePublicationReport): logger.error("Report of failure without reason.", exc_info=True) return ( - f"Publication {self.publication.id} failed with status: {self.status}.\n" + f"Publication {self.publication.id} failed with status: {self.status.name}.\n" f"Reason: {self.reason}\n" f"Publisher: {self.publication.publisher.name}\n" f"Event: {self.publication.event.name}" diff --git a/mobilizon_reshare/publishers/coordinators/event_publishing/notify.py b/mobilizon_reshare/publishers/coordinators/event_publishing/notify.py index ce0cf06..95042b7 100644 --- a/mobilizon_reshare/publishers/coordinators/event_publishing/notify.py +++ b/mobilizon_reshare/publishers/coordinators/event_publishing/notify.py @@ -1,33 +1,92 @@ from abc import ABC, abstractmethod -from typing import List +from dataclasses import dataclass, field +from typing import List, Optional, Sequence +from mobilizon_reshare.dataclasses import PublicationNotification, EventPublication +from mobilizon_reshare.models.notification import NotificationStatus from mobilizon_reshare.models.publication import PublicationStatus from mobilizon_reshare.publishers import get_active_notifiers -from mobilizon_reshare.publishers.abstract import AbstractPlatform -from mobilizon_reshare.publishers.coordinators import logger -from mobilizon_reshare.publishers.coordinators.event_publishing.publish import ( +from mobilizon_reshare.publishers.abstract import ( + AbstractPlatform, +) +from mobilizon_reshare.publishers.coordinators import ( + logger, + BasePublicationReport, + BaseCoordinatorReport, +) +from mobilizon_reshare.publishers.coordinators.event_publishing import ( EventPublicationReport, ) -from mobilizon_reshare.publishers.platforms.platform_mapping import get_notifier_class +from mobilizon_reshare.publishers.platforms.platform_mapping import ( + get_notifier_class, + get_formatter_class, +) + + +@dataclass +class PublicationNotificationReport(BasePublicationReport): + status: NotificationStatus + notification: PublicationNotification + + @property + def successful(self): + return self.status == NotificationStatus.COMPLETED + + def get_failure_message(self): + if not self.reason: + logger.error("Report of failure without reason.", exc_info=True) + return ( + f"Failed with status: {self.status.name}.\n" + f"Reason: {self.reason}\n" + f"Publisher: {self.notification.publisher.name}\n" + f"Publication: {self.notification.publication.id}" + ) + + +@dataclass +class NotifierCoordinatorReport(BaseCoordinatorReport): + reports: Sequence[PublicationNotificationReport] + notifications: Sequence[PublicationNotification] = field(default_factory=list) class Sender: - def __init__(self, message: str, platforms: List[AbstractPlatform] = None): + def __init__( + self, + message: str, + publication: EventPublication, + platforms: List[AbstractPlatform] = None, + ): self.message = message self.platforms = platforms + self.publication = publication - def send_to_all(self): + def send_to_all(self) -> NotifierCoordinatorReport: + reports = [] + notifications = [] for platform in self.platforms: + notification = PublicationNotification( + platform, get_formatter_class(platform.name)(), self.publication + ) try: platform.send(self.message) + report = PublicationNotificationReport( + NotificationStatus.COMPLETED, self.message, notification + ) except Exception as e: - logger.critical(f"Failed to send message:\n{self.message}") + msg = f"[{platform.name}] Failed to notify failure of message:\n{self.message}" + logger.critical(msg) logger.exception(e) + report = PublicationNotificationReport( + NotificationStatus.FAILED, msg, notification + ) + notifications.append(notification) + reports.append(report) + return NotifierCoordinatorReport(reports=reports, notifications=notifications) class AbstractNotifiersCoordinator(ABC): def __init__( - self, report: EventPublicationReport, notifiers: List[AbstractPlatform] = None + self, report: BasePublicationReport, notifiers: List[AbstractPlatform] = None ): self.platforms = notifiers or [ get_notifier_class(notifier)() for notifier in get_active_notifiers() @@ -44,10 +103,17 @@ class PublicationFailureNotifiersCoordinator(AbstractNotifiersCoordinator): Sends a notification of a failure report to the active platforms """ - def notify_failure(self): + report: EventPublicationReport + platforms: List[AbstractPlatform] + + def notify_failure(self) -> Optional[NotifierCoordinatorReport]: logger.info("Sending failure notifications") if self.report.status == PublicationStatus.FAILED: - Sender(self.report.get_failure_message(), self.platforms).send_to_all() + return Sender( + self.report.get_failure_message(), + self.report.publication, + self.platforms, + ).send_to_all() class PublicationFailureLoggerCoordinator(PublicationFailureNotifiersCoordinator): diff --git a/mobilizon_reshare/storage/query/read.py b/mobilizon_reshare/storage/query/read.py index 120bd6d..337c526 100644 --- a/mobilizon_reshare/storage/query/read.py +++ b/mobilizon_reshare/storage/query/read.py @@ -33,7 +33,7 @@ async def get_all_publishers() -> list[Publisher]: async def prefetch_event_relations(queryset: QuerySet[Event]) -> list[Event]: return ( - await queryset.prefetch_related("publications__publisher") + await queryset.prefetch_related("publications__publisher", "publications__notifications") .order_by("begin_datetime") .distinct() ) @@ -46,6 +46,7 @@ async def prefetch_publication_relations( await queryset.prefetch_related( "publisher", "event", + "notifications", "event__publications", "event__publications__publisher", ) diff --git a/mobilizon_reshare/storage/query/write.py b/mobilizon_reshare/storage/query/write.py index dbcb862..e858b1b 100644 --- a/mobilizon_reshare/storage/query/write.py +++ b/mobilizon_reshare/storage/query/write.py @@ -9,11 +9,15 @@ from mobilizon_reshare.dataclasses.event import ( get_mobilizon_events_without_publications, ) from mobilizon_reshare.models.event import Event +from mobilizon_reshare.models.notification import Notification from mobilizon_reshare.models.publication import Publication from mobilizon_reshare.models.publisher import Publisher from mobilizon_reshare.publishers.coordinators.event_publishing import ( EventPublicationReport, ) +from mobilizon_reshare.publishers.coordinators.event_publishing.notify import ( + NotifierCoordinatorReport, +) from mobilizon_reshare.publishers.coordinators.event_publishing.publish import ( PublisherCoordinatorReport, ) @@ -64,6 +68,24 @@ async def save_publication_report( await upsert_publication(publication_report, event) +@atomic() +async def save_notification_report( + coordinator_report: NotifierCoordinatorReport, +) -> None: + """ + Store a notification process outcome + """ + for report in coordinator_report.reports: + publisher = await Publisher.filter(name=report.notification.publisher.name).first() + + await Notification.create( + publication_id=report.notification.publication.id, + target_id=publisher.id, + status=report.status, + message=report.reason, + ) + + @atomic() async def create_unpublished_events( events_from_mobilizon: Iterable[MobilizonEvent], diff --git a/sample_settings/docker_web/settings.toml b/sample_settings/docker_web/settings.toml index eeaf3df..217c2b9 100644 --- a/sample_settings/docker_web/settings.toml +++ b/sample_settings/docker_web/settings.toml @@ -2,8 +2,9 @@ debug = false default = true local_state_dir = "/var/lib/mobilizon-reshare" -#db_path = "@format {this.local_state_dir}/events.db" +#db_url = "@format sqlite://{this.local_state_dir}/events.db" db_url = "@format postgres://mobilizon_reshare:mobilizon_reshare@db:5432/mobilizon_reshare" +locale = "en-uk" [default.source.mobilizon] url="https://some-mobilizon.com/api" @@ -28,6 +29,15 @@ class = "logging.StreamHandler" formatter = "standard" stream = "ext://sys.stderr" +[default.logging.handlers.file] +level = "INFO" +class = "logging.handlers.RotatingFileHandler" +formatter = "standard" +filename = "@format {this.local_state_dir}/mobilizon_reshare.log" +maxBytes = 52428800 +backupCount = 500 +encoding = "utf8" + [default.logging.root] -level = "DEBUG" -handlers = ['console'] +level = "INFO" +handlers = ['console', 'file'] diff --git a/tests/commands/conftest.py b/tests/commands/conftest.py index 87d0c38..a45f873 100644 --- a/tests/commands/conftest.py +++ b/tests/commands/conftest.py @@ -8,7 +8,6 @@ import mobilizon_reshare.publishers import mobilizon_reshare.storage.query.read from mobilizon_reshare.models.publisher import Publisher import mobilizon_reshare.main.recap -from mobilizon_reshare.publishers.coordinators.event_publishing import notify from tests import today from tests.conftest import event_1, event_0 @@ -138,15 +137,41 @@ async def mock_notifier_config(monkeypatch, publisher_class, mock_formatter_clas return mock_formatter_class monkeypatch.setattr( - notify, "get_notifier_class", _mock_notifier_class, + mobilizon_reshare.publishers.coordinators.event_publishing.notify, + "get_notifier_class", + _mock_notifier_class, + ) + monkeypatch.setattr( + mobilizon_reshare.publishers.coordinators.event_publishing.notify, + "get_formatter_class", + _mock_format_class, + ) + monkeypatch.setattr( + mobilizon_reshare.publishers.coordinators.event_publishing.notify, + "get_notifier_class", + _mock_notifier_class, ) monkeypatch.setattr( mobilizon_reshare.publishers.platforms.platform_mapping, "get_formatter_class", _mock_format_class, ) + monkeypatch.setattr( + mobilizon_reshare.publishers.coordinators.event_publishing.notify, + "get_formatter_class", + _mock_format_class, + ) - monkeypatch.setattr(notify, "get_active_notifiers", _mock_active_notifier) + monkeypatch.setattr( + mobilizon_reshare.publishers.coordinators.event_publishing.notify, + "get_active_notifiers", + _mock_active_notifier, + ) + monkeypatch.setattr( + mobilizon_reshare.config.notifiers, + "get_active_notifiers", + lambda s: [], + ) @pytest.fixture diff --git a/tests/commands/test_publish.py b/tests/commands/test_publish.py index bf7d4d2..eace785 100644 --- a/tests/commands/test_publish.py +++ b/tests/commands/test_publish.py @@ -1,13 +1,15 @@ from logging import DEBUG +from uuid import UUID import pytest from mobilizon_reshare.dataclasses import EventPublicationStatus from mobilizon_reshare.dataclasses import MobilizonEvent from mobilizon_reshare.main.publish import select_and_publish, publish_event +from mobilizon_reshare.models.notification import NotificationStatus, Notification from mobilizon_reshare.models.event import Event from mobilizon_reshare.models.publication import PublicationStatus -from mobilizon_reshare.storage.query.read import get_all_publications +from mobilizon_reshare.storage.query.read import get_all_publications, get_event from tests.conftest import event_0, event_1 one_unpublished_event_specification = { @@ -112,3 +114,50 @@ async def test_publish_event( assert len(publications) == len(expected) assert all(p.status == PublicationStatus.COMPLETED for p in publications) assert {p.publisher.name for p in publications} == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "publisher_class", [pytest.lazy_fixture("mock_publisher_invalid_class")] +) +async def test_notify_publisher_failure( + caplog, + mock_publisher_config, + message_collector, + generate_models, + mock_notifier_config, + command_config, +): + await generate_models(one_unpublished_event_specification) + + with caplog.at_level(DEBUG): + # calling the publish command + result = await select_and_publish(command_config) + + assert not result.successful + assert len(result.reports) == 1 + assert result.reports[0].published_content is None + + # since the db contains at least one event, this has to be picked and published + event_model = await get_event(UUID(int=0)) + # it should create a publication for each publisher and a notification for each notifier + publications = event_model.publications + assert len(publications) == 1, publications + publication = publications[0] + notifications: list[Notification] = list(publications[0].notifications) + assert len(notifications) == 2, notifications + + # all the publications for event should be saved as FAILED + for n in notifications: + assert n.status == NotificationStatus.COMPLETED + assert ( + n.message + == f"Publication {publication.id} failed with status: FAILED.\nReason: credentials error" + "\nPublisher: mock\nEvent: event_0" + ) + + # the derived status for the event should be FAILED + assert ( + MobilizonEvent.from_model(event_model).status + == EventPublicationStatus.FAILED + ) diff --git a/tests/commands/test_retry.py b/tests/commands/test_retry.py index f9d5b48..57ada0d 100644 --- a/tests/commands/test_retry.py +++ b/tests/commands/test_retry.py @@ -122,16 +122,16 @@ async def test_retry_publication_missing( async def test_event_retry_failure( event_with_failed_publication, mock_publisher_config, + mock_notifier_config, failed_publication: Publication, - caplog, ): - with caplog.at_level(ERROR): - await retry_event(event_with_failed_publication.mobilizon_id) - assert ( - f"Publication {failed_publication.id} failed with status: 0.\nReason: credentials error" - in caplog.text - ) + report = await retry_event(event_with_failed_publication.mobilizon_id) + assert len(report.reports) == 1 + assert ( + f"Publication {failed_publication.id} failed with status: FAILED.\nReason: credentials error" + in report.reports[0].get_failure_message() + ) p = await Publication.filter(id=failed_publication.id).first() assert p.status == PublicationStatus.FAILED, p.id @@ -144,15 +144,17 @@ async def test_event_retry_failure( async def test_publication_retry_failure( event_with_failed_publication, mock_publisher_config, + mock_notifier_config, failed_publication: Publication, caplog, ): with caplog.at_level(ERROR): - await retry_publication(failed_publication.id) + report = await retry_publication(failed_publication.id) + assert len(report.reports) == 1 assert ( - f"Publication {failed_publication.id} failed with status: 0.\nReason: credentials error" - in caplog.text + f"Publication {failed_publication.id} failed with status: FAILED.\nReason: credentials error" + in report.reports[0].get_failure_message() ) p = await Publication.filter(id=failed_publication.id).first() assert p.status == PublicationStatus.FAILED, p.id diff --git a/tests/commands/test_start.py b/tests/commands/test_start.py index 01d307a..5ec0700 100644 --- a/tests/commands/test_start.py +++ b/tests/commands/test_start.py @@ -186,7 +186,7 @@ async def test_start_publisher_failure( assert "Event to publish found" in caplog.text assert message_collector == [ - f"Publication {p.id} failed with status: 0." + f"Publication {p.id} failed with status: FAILED." f"\nReason: credentials error\nPublisher: mock\nEvent: test event" for p in publications for _ in range(2) diff --git a/tests/conftest.py b/tests/conftest.py index 34bf074..dcd906b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,7 +51,7 @@ def generate_event_status(published): def generate_notification_status(published): - return NotificationStatus.COMPLETED if published else NotificationStatus.WAITING + return NotificationStatus.COMPLETED if published else NotificationStatus.FAILED @pytest.fixture(scope="session", autouse=True) @@ -421,6 +421,12 @@ def mock_publisher_valid(message_collector, mock_publisher_class): return mock_publisher_class() +@pytest.fixture +def mock_zulip_publisher(message_collector, mock_zulip_publisher_class): + + return mock_zulip_publisher_class() + + @pytest.fixture def mobilizon_url(): return get_settings()["source"]["mobilizon"]["url"] diff --git a/tests/models/test_notification.py b/tests/models/test_notification.py index b1f3afa..c1d38c1 100644 --- a/tests/models/test_notification.py +++ b/tests/models/test_notification.py @@ -8,5 +8,5 @@ async def test_notification_create(notification_model_generator): notification_model = notification_model_generator() await notification_model.save() notification_db = await Notification.all().first() - assert notification_db.status == NotificationStatus.WAITING + assert notification_db.status == NotificationStatus.FAILED assert notification_db.message == "message_1" diff --git a/tests/publishers/conftest.py b/tests/publishers/conftest.py index da94e2e..f1b34f5 100644 --- a/tests/publishers/conftest.py +++ b/tests/publishers/conftest.py @@ -78,3 +78,21 @@ def mock_publisher_invalid_response(message_collector): pass return MockPublisher() + + +@pytest.fixture +def mock_zulip_publisher_invalid_response(message_collector): + class MockPublisher(AbstractPlatform): + + name = "zulip" + + def _send(self, message, event): + message_collector.append(message) + + def _validate_response(self, response): + raise InvalidResponse("Invalid response") + + def validate_credentials(self) -> None: + pass + + return MockPublisher() diff --git a/tests/publishers/test_coordinator.py b/tests/publishers/test_coordinator.py index a305807..5cfce0a 100644 --- a/tests/publishers/test_coordinator.py +++ b/tests/publishers/test_coordinator.py @@ -115,8 +115,12 @@ async def mock_publications( @pytest.mark.parametrize("num_publications", [2]) @pytest.mark.asyncio -async def test_publication_coordinator_run_success(mock_publications,): - coordinator = PublisherCoordinator(publications=mock_publications,) +async def test_publication_coordinator_run_success( + mock_publications, +): + coordinator = PublisherCoordinator( + publications=mock_publications, + ) report = coordinator.run() assert len(report.reports) == 2 assert report.successful, "\n".join(map(lambda rep: rep.reason, report.reports)) @@ -173,12 +177,12 @@ async def test_publication_coordinator_run_failure_response( @pytest.mark.asyncio async def test_notifier_coordinator_publication_failed( - mock_publisher_valid, failure_report + mock_zulip_publisher, failure_report ): mock_send = MagicMock() - mock_publisher_valid._send = mock_send + mock_zulip_publisher._send = mock_send coordinator = PublicationFailureNotifiersCoordinator( - failure_report, [mock_publisher_valid, mock_publisher_valid] + failure_report, [mock_zulip_publisher, mock_zulip_publisher] ) coordinator.notify_failure() @@ -188,18 +192,18 @@ async def test_notifier_coordinator_publication_failed( @pytest.mark.asyncio async def test_notifier_coordinator_error( - failure_report, mock_publisher_invalid_response, caplog + failure_report, mock_zulip_publisher_invalid_response, caplog ): mock_send = MagicMock() - mock_publisher_invalid_response._send = mock_send + mock_zulip_publisher_invalid_response._send = mock_send coordinator = PublicationFailureNotifiersCoordinator( failure_report, - [mock_publisher_invalid_response, mock_publisher_invalid_response], + [mock_zulip_publisher_invalid_response, mock_zulip_publisher_invalid_response], ) with caplog.at_level(logging.CRITICAL): coordinator.notify_failure() - assert "Failed to send" in caplog.text + assert "Failed to notify failure of" in caplog.text assert failure_report.get_failure_message() in caplog.text # 4 = 2 reports * 2 notifiers assert mock_send.call_count == 2 diff --git a/tests/storage/test_query.py b/tests/storage/test_query.py index 617d681..a12afe8 100644 --- a/tests/storage/test_query.py +++ b/tests/storage/test_query.py @@ -9,10 +9,15 @@ from mobilizon_reshare.dataclasses.event import ( get_mobilizon_events_with_status, get_mobilizon_events_without_publications, ) +from mobilizon_reshare.storage.query.read import ( + get_all_events, + get_event, +) from mobilizon_reshare.dataclasses.publication import build_publications_for_event from mobilizon_reshare.models.publication import PublicationStatus from mobilizon_reshare.storage.query.read import publications_with_status from tests import today +from tests.commands.test_publish import one_unpublished_event_specification from tests.conftest import event_0, event_1, event_3 from tests.storage import complete_specification from tests.storage import result_publication @@ -153,6 +158,14 @@ async def test_events_without_publications(spec, expected_events, generate_model assert unpublished_events == expected_events +@pytest.mark.asyncio +async def test_get_all_events(generate_models): + await generate_models(one_unpublished_event_specification) + + all_events = [await get_event(event_0.mobilizon_id)] + assert list(await get_all_events()) == all_events + + @pytest.mark.asyncio @pytest.mark.parametrize( "mock_active_publishers, spec, event, n_publications", diff --git a/tests/storage/test_read_query.py b/tests/storage/test_read_query.py deleted file mode 100644 index 6d13b74..0000000 --- a/tests/storage/test_read_query.py +++ /dev/null @@ -1,16 +0,0 @@ -from uuid import UUID - -import pytest - -from mobilizon_reshare.dataclasses.event import get_all_mobilizon_events - - -@pytest.mark.asyncio -async def test_get_all_events(event_generator): - all_events = [ - event_generator(mobilizon_id=UUID(int=i), published=False) for i in range(4) - ] - for e in all_events: - await e.to_model().save() - - assert list(await get_all_mobilizon_events()) == all_events