From 64572f6205b162265d0db00181d3f31f8b33e404 Mon Sep 17 00:00:00 2001 From: tenkus47 Date: Thu, 16 Jul 2026 14:44:23 +0530 Subject: [PATCH 1/2] Add APScheduler for verse cleanup and implement expiration logic - Integrated APScheduler to manage daily cleanup of expired verses of the day. - Added configuration for verse retention period in `config.py`. - Implemented `cleanup_expired_verses_of_day` function to delete verses older than the specified expiry days. - Updated database interaction to support deletion of expired verses. - Created tests to validate the cleanup functionality and ensure correct behavior. --- pecha_api/config.py | 3 + pecha_api/db/mongo_database.py | 4 ++ pecha_api/scheduler.py | 35 +++++++++++ .../verse_of_day/verse_of_day_repository.py | 10 ++++ .../verse_of_day/verse_of_day_service.py | 17 +++++- poetry.lock | 58 ++++++++++++++++++- pyproject.toml | 1 + tests/bookmarks/test_bookmark_utils.py | 46 +++++---------- tests/db/test_mongo_database_lifespan.py | 6 +- .../plans/public/test_plan_public_service.py | 3 +- .../verse_of_day/test_verse_of_day_service.py | 17 ++++++ 11 files changed, 165 insertions(+), 35 deletions(-) create mode 100644 pecha_api/scheduler.py diff --git a/pecha_api/config.py b/pecha_api/config.py index 793fa2c3e..2478b1cc0 100644 --- a/pecha_api/config.py +++ b/pecha_api/config.py @@ -109,6 +109,9 @@ NOTIFICATION_DEFAULT_TITLE="WebBuddhist", NOTIFICATION_DEFAULT_BODY="Time for your daily practice.", + # Verse of the day retention (days); scheduler deletes older rows daily + VERSE_OF_DAY_EXPIRY_DAYS=7, + ) TIME_FORMAT_PATTERN = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$") diff --git a/pecha_api/db/mongo_database.py b/pecha_api/db/mongo_database.py index 687024e6b..d82319107 100644 --- a/pecha_api/db/mongo_database.py +++ b/pecha_api/db/mongo_database.py @@ -13,6 +13,7 @@ from ..texts.texts_models import TableOfContent from ..texts.groups.groups_models import Group from ..config import get +from ..scheduler import setup_scheduler, shutdown_scheduler mongodb_client = None mongodb = None @@ -35,7 +36,10 @@ async def lifespan(api: FastAPI): logging.error(f"Error during collection initialization: {e}") raise + setup_scheduler() + yield + shutdown_scheduler() if mongodb_client: mongodb_client.close() \ No newline at end of file diff --git a/pecha_api/scheduler.py b/pecha_api/scheduler.py new file mode 100644 index 000000000..1b62dde4c --- /dev/null +++ b/pecha_api/scheduler.py @@ -0,0 +1,35 @@ +import logging + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger + +from pecha_api.config import get_int +from pecha_api.verse_of_day.verse_of_day_service import cleanup_expired_verses_of_day + +logger = logging.getLogger(__name__) + +scheduler = AsyncIOScheduler() + + +def setup_scheduler() -> None: + expiry_days = get_int("VERSE_OF_DAY_EXPIRY_DAYS") + scheduler.add_job( + cleanup_expired_verses_of_day, + CronTrigger(hour=0, minute=0), + args=[expiry_days], + id="cleanup_expired_verses_of_day", + name="Cleanup expired verses of the day", + replace_existing=True, + ) + if not scheduler.running: + scheduler.start() + logger.info( + "Scheduler started: cleaning verses of the day older than %s day(s) daily at midnight", + expiry_days, + ) + + +def shutdown_scheduler() -> None: + if scheduler.running: + scheduler.shutdown(wait=False) + logger.info("Scheduler shut down") diff --git a/pecha_api/verse_of_day/verse_of_day_repository.py b/pecha_api/verse_of_day/verse_of_day_repository.py index f9c6b82b4..ebd4f25a3 100644 --- a/pecha_api/verse_of_day/verse_of_day_repository.py +++ b/pecha_api/verse_of_day/verse_of_day_repository.py @@ -176,3 +176,13 @@ def delete_verse_of_day(db: Session, verse_id: UUID) -> bool: db.delete(verse) db.commit() return True + + +def delete_verses_of_day_older_than(db: Session, cutoff_date: date) -> int: + deleted_count = ( + db.query(VerseOfDay) + .filter(VerseOfDay.date < cutoff_date) + .delete(synchronize_session=False) + ) + db.commit() + return deleted_count diff --git a/pecha_api/verse_of_day/verse_of_day_service.py b/pecha_api/verse_of_day/verse_of_day_service.py index 1c6ea2a1c..e7820269a 100644 --- a/pecha_api/verse_of_day/verse_of_day_service.py +++ b/pecha_api/verse_of_day/verse_of_day_service.py @@ -1,6 +1,6 @@ from typing import Optional, Dict, List from uuid import UUID -from datetime import date +from datetime import date, datetime, timedelta, timezone import logging from pecha_api.timezone_utils import get_date_in_timezone @@ -18,7 +18,8 @@ get_group_metadata_by_group_id, update_verse_of_day, delete_verse_metadata_by_verse_id, - delete_verse_of_day + delete_verse_of_day, + delete_verses_of_day_older_than, ) from .verse_of_day_response_models import ( VerseOfDayPublicDTO, @@ -364,3 +365,15 @@ def delete_verse_of_day_service(verse_id: UUID) -> None: ) delete_verse_of_day(db, verse_id) + + +def cleanup_expired_verses_of_day(expiry_days: int) -> int: + cutoff_date = datetime.now(timezone.utc).date() - timedelta(days=expiry_days) + with SessionLocal() as db: + deleted_count = delete_verses_of_day_older_than(db, cutoff_date) + logger.info( + "Deleted %s verse(s) of the day older than %s", + deleted_count, + cutoff_date, + ) + return deleted_count diff --git a/poetry.lock b/poetry.lock index 8f1c078b2..6df7a62e4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -62,6 +62,33 @@ doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] trio = ["trio (>=0.26.1)"] +[[package]] +name = "apscheduler" +version = "3.11.3" +description = "In-process task scheduler with Cron-like capabilities" +optional = false +python-versions = ">=3.8" +files = [ + {file = "apscheduler-3.11.3-py3-none-any.whl", hash = "sha256:bbeb2ec02d23d3c06a6c07ed7f0f3939ada6680eb121fae809a69bb42c537a30"}, + {file = "apscheduler-3.11.3.tar.gz", hash = "sha256:cd2fcc9330039a81a5893472ad49facf23a6d5604cbe1d918c835c6de7834d5a"}, +] + +[package.dependencies] +tzlocal = ">=3.0" + +[package.extras] +doc = ["packaging", "sphinx", "sphinx-rtd-theme (>=1.3.0)"] +etcd = ["etcd3", "protobuf (<=3.21.0)"] +gevent = ["gevent"] +mongodb = ["pymongo (>=3.0)"] +redis = ["redis (>=3.0)"] +rethinkdb = ["rethinkdb (>=2.4.0)"] +sqlalchemy = ["sqlalchemy (>=1.4)"] +test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6", "anyio (>=4.5.2)", "gevent", "pytest", "pytest-timeout", "pytz", "twisted"] +tornado = ["tornado (>=4.3)"] +twisted = ["twisted"] +zookeeper = ["kazoo"] + [[package]] name = "attrs" version = "25.1.0" @@ -2231,6 +2258,35 @@ files = [ [package.dependencies] typing-extensions = ">=4.12.0" +[[package]] +name = "tzdata" +version = "2026.3" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931"}, + {file = "tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415"}, +] + +[[package]] +name = "tzlocal" +version = "5.4.4" +description = "tzinfo object for the local timezone" +optional = false +python-versions = ">=3.10" +files = [ + {file = "tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15"}, + {file = "tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4"}, +] + +[package.dependencies] +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +devenv = ["zest.releaser"] +testing = ["check_manifest", "pyroma", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "ruff"] + [[package]] name = "urllib3" version = "2.3.0" @@ -2488,4 +2544,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "e55bc47074c068a1628644f98bf9fd4e1aa6d28fab0348f5dc84143bf2fccb61" +content-hash = "53907354b2e93d78505fc499b0e60a5f8c4fa6b794aa48fcb8731b75e1912e9c" diff --git a/pyproject.toml b/pyproject.toml index 0d662554b..158cd0489 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ mailtrap = "^2.3.0" google-genai = "^2.0" scalar-fastapi = "^1.8.2" +apscheduler = "^3.11.3" [tool.poetry.scripts] start = "uvicorn:main" diff --git a/tests/bookmarks/test_bookmark_utils.py b/tests/bookmarks/test_bookmark_utils.py index a384b7808..ad1cd5723 100644 --- a/tests/bookmarks/test_bookmark_utils.py +++ b/tests/bookmarks/test_bookmark_utils.py @@ -239,9 +239,13 @@ async def test_enrich_text_bookmark_returns_empty_when_segment_missing(): bookmark.name = None with patch( - "pecha_api.bookmarks.bookmark_utils._resolve_text_segment", + "pecha_api.bookmarks.bookmark_utils.get_texts_by_id", new_callable=AsyncMock, - return_value=(None, None), + return_value=MagicMock(title="Sample text"), + ), patch( + "pecha_api.bookmarks.bookmark_utils.build_first_segment_preview_for_text", + new_callable=AsyncMock, + return_value=None, ): result = await enrich_text_bookmark(bookmark) @@ -322,18 +326,14 @@ async def test_enrich_text_bookmark_handles_missing_text_details(): bookmark.source_id = text_id bookmark.name = None - mock_segment = MagicMock() - mock_segment.id = segment_id - mock_segment.content = "Segment content" - with patch( - "pecha_api.bookmarks.bookmark_utils._resolve_text_segment", - new_callable=AsyncMock, - return_value=(segment_id, mock_segment), - ), patch( "pecha_api.bookmarks.bookmark_utils.get_texts_by_id", new_callable=AsyncMock, return_value=None, + ), patch( + "pecha_api.bookmarks.bookmark_utils.build_first_segment_preview_for_text", + new_callable=AsyncMock, + return_value=(segment_id, "Segment content"), ): result = await enrich_text_bookmark(bookmark) @@ -353,27 +353,18 @@ async def test_enrich_text_bookmark_with_language_uses_localized_text(): bookmark.source_id = text_id bookmark.name = None - mock_segment = MagicMock() - mock_segment.id = segment_id - mock_segment.text_id = text_id - mock_segment.content = "English content" - localized_text = MagicMock() localized_text.id = localized_text_id localized_text.title = "བོད་ཡིག་ཁ་བྱང་" with patch( - "pecha_api.bookmarks.bookmark_utils._resolve_text_segment", - new_callable=AsyncMock, - return_value=(segment_id, mock_segment), - ), patch( "pecha_api.bookmarks.bookmark_utils._resolve_localized_text", new_callable=AsyncMock, return_value=localized_text, ), patch( - "pecha_api.bookmarks.bookmark_utils._resolve_localized_segment", + "pecha_api.bookmarks.bookmark_utils.build_first_segment_preview_for_text", new_callable=AsyncMock, - return_value=mock_segment, + return_value=(segment_id, "English content"), ): result = await enrich_text_bookmark(bookmark, language="BO") @@ -808,19 +799,10 @@ async def test_enrich_text_bookmark_falls_back_when_localized_text_missing(): bookmark.source_id = text_id bookmark.name = None - mock_segment = MagicMock() - mock_segment.id = segment_id - mock_segment.text_id = text_id - mock_segment.content = "Fallback content" - mock_text = MagicMock() mock_text.title = "Original title" with patch( - "pecha_api.bookmarks.bookmark_utils._resolve_text_segment", - new_callable=AsyncMock, - return_value=(segment_id, mock_segment), - ), patch( "pecha_api.bookmarks.bookmark_utils._resolve_localized_text", new_callable=AsyncMock, return_value=None, @@ -828,6 +810,10 @@ async def test_enrich_text_bookmark_falls_back_when_localized_text_missing(): "pecha_api.bookmarks.bookmark_utils.get_texts_by_id", new_callable=AsyncMock, return_value=mock_text, + ), patch( + "pecha_api.bookmarks.bookmark_utils.build_first_segment_preview_for_text", + new_callable=AsyncMock, + return_value=(segment_id, "Fallback content"), ): result = await enrich_text_bookmark(bookmark, language="BO") diff --git a/tests/db/test_mongo_database_lifespan.py b/tests/db/test_mongo_database_lifespan.py index e3c22bef9..6e3a2dcdf 100644 --- a/tests/db/test_mongo_database_lifespan.py +++ b/tests/db/test_mongo_database_lifespan.py @@ -18,7 +18,9 @@ async def test_lifespan_closes_mongo_client_on_shutdown(): "MONGO_CONNECTION_STRING": "mongodb://localhost:27017", "MONGO_DATABASE_NAME": "testdb", }[key], - ): + ), patch("pecha_api.db.mongo_database.setup_scheduler") as mock_setup_scheduler, patch( + "pecha_api.db.mongo_database.shutdown_scheduler" + ) as mock_shutdown_scheduler: mock_client = MagicMock() mock_client_cls.return_value = mock_client mock_db = MagicMock() @@ -27,4 +29,6 @@ async def test_lifespan_closes_mongo_client_on_shutdown(): async with lifespan(api): assert api.mongodb is mock_db + mock_setup_scheduler.assert_called_once() + mock_shutdown_scheduler.assert_called_once() mock_client.close.assert_called_once() diff --git a/tests/plans/public/test_plan_public_service.py b/tests/plans/public/test_plan_public_service.py index 62346abd4..6b2643020 100644 --- a/tests/plans/public/test_plan_public_service.py +++ b/tests/plans/public/test_plan_public_service.py @@ -1002,7 +1002,8 @@ async def test_get_plan_daily_content_resolves_plan_by_date_in_series(): ) assert result.plan_id == plan_one_id - mock_series_plans.assert_called_once_with( + assert mock_series_plans.call_count == 2 + mock_series_plans.assert_called_with( db=mock_db, series_id=series_id, language="en" ) mock_day_fn.assert_called_once_with(db=mock_db, plan_id=plan_one_id, day_number=3) diff --git a/tests/verse_of_day/test_verse_of_day_service.py b/tests/verse_of_day/test_verse_of_day_service.py index 39bae8718..b30bb860d 100644 --- a/tests/verse_of_day/test_verse_of_day_service.py +++ b/tests/verse_of_day/test_verse_of_day_service.py @@ -11,6 +11,7 @@ create_verse_of_day_service, update_verse_of_day_service, delete_verse_of_day_service, + cleanup_expired_verses_of_day, build_verses_dict, build_public_dto, _generate_verse_image_url, @@ -1534,3 +1535,19 @@ def test_generate_verse_image_url_all_fail_returns_none(): result = _generate_verse_image_url(s3_keys) assert result is None # No valid URLs generated + + +def test_cleanup_expired_verses_of_day(mock_db_session): + """Test cleanup deletes verses older than the expiry window.""" + from datetime import datetime, timedelta, timezone + + with patch("pecha_api.verse_of_day.verse_of_day_service.SessionLocal", return_value=mock_db_session), \ + patch("pecha_api.verse_of_day.verse_of_day_service.delete_verses_of_day_older_than", return_value=3) as mock_delete: + + result = cleanup_expired_verses_of_day(expiry_days=7) + + assert result == 3 + mock_delete.assert_called_once() + cutoff_date = mock_delete.call_args[0][1] + expected_cutoff = datetime.now(timezone.utc).date() - timedelta(days=7) + assert cutoff_date == expected_cutoff From 39c22bc992c5df0178fdb58e8fafed22e18748db Mon Sep 17 00:00:00 2001 From: tenkus47 Date: Fri, 17 Jul 2026 10:28:23 +0530 Subject: [PATCH 2/2] Enhance error handling for scheduler and cleanup functions - Added validation to ensure `VERSE_OF_DAY_EXPIRY_DAYS` and `expiry_days` are positive integers, raising `ValueError` for invalid inputs. - Updated the `lifespan` function to ensure proper cleanup of resources during initialization failures. - Added tests to verify the new validation logic for both the scheduler setup and the cleanup function. --- pecha_api/db/mongo_database.py | 40 ++++++----- pecha_api/scheduler.py | 4 ++ .../verse_of_day/verse_of_day_service.py | 2 + tests/db/test_mongo_database_lifespan.py | 59 ++++++++++++++++ tests/test_scheduler.py | 67 +++++++++++++++++++ .../verse_of_day/test_verse_of_day_service.py | 13 ++++ 6 files changed, 170 insertions(+), 15 deletions(-) create mode 100644 tests/test_scheduler.py diff --git a/pecha_api/db/mongo_database.py b/pecha_api/db/mongo_database.py index d82319107..248e91347 100644 --- a/pecha_api/db/mongo_database.py +++ b/pecha_api/db/mongo_database.py @@ -27,19 +27,29 @@ async def lifespan(api: FastAPI): mongodb = mongodb_client[get("MONGO_DATABASE_NAME")] api.mongodb = mongodb # Attach the database instance to the FastAPI app - # Initialize collections and indexes if necessary try: - await init_beanie(database=mongodb,document_models=[Collection, Term, Topic, Text, Segment, TableOfContent, Group]) - logging.info("Beanie initialized with the 'terms' collection.") - - except Exception as e: - logging.error(f"Error during collection initialization: {e}") - raise - - setup_scheduler() - - yield - - shutdown_scheduler() - if mongodb_client: - mongodb_client.close() \ No newline at end of file + # Initialize collections and indexes if necessary + try: + await init_beanie( + database=mongodb, + document_models=[ + Collection, + Term, + Topic, + Text, + Segment, + TableOfContent, + Group, + ], + ) + logging.info("Beanie initialized with the 'terms' collection.") + except Exception as e: + logging.error(f"Error during collection initialization: {e}") + raise + + setup_scheduler() + yield + finally: + shutdown_scheduler() + if mongodb_client: + mongodb_client.close() \ No newline at end of file diff --git a/pecha_api/scheduler.py b/pecha_api/scheduler.py index 1b62dde4c..508267457 100644 --- a/pecha_api/scheduler.py +++ b/pecha_api/scheduler.py @@ -13,6 +13,10 @@ def setup_scheduler() -> None: expiry_days = get_int("VERSE_OF_DAY_EXPIRY_DAYS") + if expiry_days < 1: + raise ValueError( + f"VERSE_OF_DAY_EXPIRY_DAYS must be a positive integer, got {expiry_days}" + ) scheduler.add_job( cleanup_expired_verses_of_day, CronTrigger(hour=0, minute=0), diff --git a/pecha_api/verse_of_day/verse_of_day_service.py b/pecha_api/verse_of_day/verse_of_day_service.py index 27bda70c3..501abfb16 100644 --- a/pecha_api/verse_of_day/verse_of_day_service.py +++ b/pecha_api/verse_of_day/verse_of_day_service.py @@ -371,6 +371,8 @@ def delete_verse_of_day_service(verse_id: UUID) -> None: def cleanup_expired_verses_of_day(expiry_days: int) -> int: + if expiry_days < 1: + raise ValueError(f"expiry_days must be a positive integer, got {expiry_days}") cutoff_date = datetime.now(timezone.utc).date() - timedelta(days=expiry_days) with SessionLocal() as db: deleted_count = delete_verses_of_day_older_than(db, cutoff_date) diff --git a/tests/db/test_mongo_database_lifespan.py b/tests/db/test_mongo_database_lifespan.py index 6e3a2dcdf..37bccbbe4 100644 --- a/tests/db/test_mongo_database_lifespan.py +++ b/tests/db/test_mongo_database_lifespan.py @@ -32,3 +32,62 @@ async def test_lifespan_closes_mongo_client_on_shutdown(): mock_setup_scheduler.assert_called_once() mock_shutdown_scheduler.assert_called_once() mock_client.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_lifespan_cleans_up_when_beanie_init_fails(): + api = MagicMock() + + with patch("pecha_api.db.mongo_database.AsyncIOMotorClient") as mock_client_cls, patch( + "pecha_api.db.mongo_database.init_beanie", + new_callable=AsyncMock, + side_effect=RuntimeError("beanie failed"), + ), patch( + "pecha_api.db.mongo_database.get", + side_effect=lambda key: { + "MONGO_CONNECTION_STRING": "mongodb://localhost:27017", + "MONGO_DATABASE_NAME": "testdb", + }[key], + ), patch("pecha_api.db.mongo_database.setup_scheduler") as mock_setup_scheduler, patch( + "pecha_api.db.mongo_database.shutdown_scheduler" + ) as mock_shutdown_scheduler: + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.__getitem__.return_value = MagicMock() + + with pytest.raises(RuntimeError, match="beanie failed"): + async with lifespan(api): + pass + + mock_setup_scheduler.assert_not_called() + mock_shutdown_scheduler.assert_called_once() + mock_client.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_lifespan_cleans_up_when_scheduler_setup_fails(): + api = MagicMock() + + with patch("pecha_api.db.mongo_database.AsyncIOMotorClient") as mock_client_cls, patch( + "pecha_api.db.mongo_database.init_beanie", + new_callable=AsyncMock, + ), patch( + "pecha_api.db.mongo_database.get", + side_effect=lambda key: { + "MONGO_CONNECTION_STRING": "mongodb://localhost:27017", + "MONGO_DATABASE_NAME": "testdb", + }[key], + ), patch( + "pecha_api.db.mongo_database.setup_scheduler", + side_effect=ValueError("invalid retention"), + ), patch("pecha_api.db.mongo_database.shutdown_scheduler") as mock_shutdown_scheduler: + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.__getitem__.return_value = MagicMock() + + with pytest.raises(ValueError, match="invalid retention"): + async with lifespan(api): + pass + + mock_shutdown_scheduler.assert_called_once() + mock_client.close.assert_called_once() diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 000000000..735bd5e6b --- /dev/null +++ b/tests/test_scheduler.py @@ -0,0 +1,67 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from pecha_api.scheduler import setup_scheduler, shutdown_scheduler + + +def test_setup_scheduler_rejects_non_positive_retention(): + with patch("pecha_api.scheduler.get_int", return_value=0), patch( + "pecha_api.scheduler.scheduler" + ) as mock_scheduler: + mock_scheduler.running = False + + with pytest.raises(ValueError, match="positive integer"): + setup_scheduler() + + mock_scheduler.add_job.assert_not_called() + mock_scheduler.start.assert_not_called() + + +def test_setup_scheduler_rejects_negative_retention(): + with patch("pecha_api.scheduler.get_int", return_value=-7), patch( + "pecha_api.scheduler.scheduler" + ) as mock_scheduler: + mock_scheduler.running = False + + with pytest.raises(ValueError, match="positive integer"): + setup_scheduler() + + mock_scheduler.add_job.assert_not_called() + + +def test_setup_scheduler_registers_cleanup_job(): + with patch("pecha_api.scheduler.get_int", return_value=7), patch( + "pecha_api.scheduler.scheduler" + ) as mock_scheduler, patch( + "pecha_api.scheduler.CronTrigger" + ) as mock_cron_trigger: + mock_scheduler.running = False + mock_trigger = MagicMock() + mock_cron_trigger.return_value = mock_trigger + + setup_scheduler() + + mock_scheduler.add_job.assert_called_once() + call_kwargs = mock_scheduler.add_job.call_args + assert call_kwargs.kwargs["args"] == [7] + assert call_kwargs.kwargs["id"] == "cleanup_expired_verses_of_day" + mock_scheduler.start.assert_called_once() + + +def test_shutdown_scheduler_when_running(): + with patch("pecha_api.scheduler.scheduler") as mock_scheduler: + mock_scheduler.running = True + + shutdown_scheduler() + + mock_scheduler.shutdown.assert_called_once_with(wait=False) + + +def test_shutdown_scheduler_when_not_running(): + with patch("pecha_api.scheduler.scheduler") as mock_scheduler: + mock_scheduler.running = False + + shutdown_scheduler() + + mock_scheduler.shutdown.assert_not_called() diff --git a/tests/verse_of_day/test_verse_of_day_service.py b/tests/verse_of_day/test_verse_of_day_service.py index 5d31a260c..b468cab6d 100644 --- a/tests/verse_of_day/test_verse_of_day_service.py +++ b/tests/verse_of_day/test_verse_of_day_service.py @@ -1580,3 +1580,16 @@ def test_cleanup_expired_verses_of_day(mock_db_session): cutoff_date = mock_delete.call_args[0][1] expected_cutoff = datetime.now(timezone.utc).date() - timedelta(days=7) assert cutoff_date == expected_cutoff + + +def test_cleanup_expired_verses_of_day_rejects_non_positive_expiry(mock_db_session): + with patch("pecha_api.verse_of_day.verse_of_day_service.SessionLocal", return_value=mock_db_session), \ + patch("pecha_api.verse_of_day.verse_of_day_service.delete_verses_of_day_older_than") as mock_delete: + + with pytest.raises(ValueError, match="positive integer"): + cleanup_expired_verses_of_day(expiry_days=0) + + with pytest.raises(ValueError, match="positive integer"): + cleanup_expired_verses_of_day(expiry_days=-1) + + mock_delete.assert_not_called()