diff --git a/src/kleinanzeigen_bot/__init__.py b/src/kleinanzeigen_bot/__init__.py index d1081fe..bef9d7f 100644 --- a/src/kleinanzeigen_bot/__init__.py +++ b/src/kleinanzeigen_bot/__init__.py @@ -54,6 +54,9 @@ from .price_reduction import ( from .price_reduction import ( evaluate_auto_price_reduction as evaluate_auto_price_reduction, ) +from .price_reduction import ( + is_auto_price_reduction_due as is_auto_price_reduction_due, +) from .update_checker import UpdateChecker from .utils import diagnostics, dicts, error_handlers, loggers, misc, xdg_paths from .utils.exceptions import CaptchaEncountered, CategoryResolutionError, PublishedAdsFetchIncompleteError, PublishSubmissionUncertainError @@ -1084,6 +1087,8 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 # Check for 'changed' selector if "changed" in selectors and self.__check_ad_changed(ad_cfg, ad_cfg_orig, ad_file_relative): should_include = True + elif "changed" in selectors and self.command == "update" and is_auto_price_reduction_due(ad_cfg, ad_file_relative): + should_include = True # Check for 'new' selector if "new" in selectors and (not ad_cfg.id or not exclude_ads_with_id): diff --git a/src/kleinanzeigen_bot/price_reduction.py b/src/kleinanzeigen_bot/price_reduction.py index e54f1a2..21ca8bc 100644 --- a/src/kleinanzeigen_bot/price_reduction.py +++ b/src/kleinanzeigen_bot/price_reduction.py @@ -9,6 +9,7 @@ __all__ = [ "_log_auto_price_reduction_preview", "apply_auto_price_reduction", "evaluate_auto_price_reduction", + "is_auto_price_reduction_due", ] from dataclasses import dataclass @@ -297,6 +298,45 @@ def evaluate_auto_price_reduction(ad_cfg:Ad, _ad_file_relative:str, *, mode:AdUp ) +def is_auto_price_reduction_due(ad_cfg:Ad, ad_file_relative:str) -> bool: + """Check if an ad has a pending auto price reduction that should trigger an update. + + Evaluates the auto price reduction in MODIFY mode without mutating the ad. + A reduction is 'due' when it is enabled, the delay criteria are satisfied, + a next cycle is calculated, and the result price differs from the restored price. + + Note: + ``no_visible_change`` reductions (where the calculated price does not + change, but ``price_reduction_count`` would still advance) are not + considered 'due' here, since only visible price changes warrant a + browser update. + + Args: + ad_cfg: The ad configuration to evaluate. + ad_file_relative: Relative path to the ad file (for logging context). + + Returns: + True if a price reduction is due for this ad. + """ + if not ad_cfg.id: + # New ads don't have pending reductions + return False + + decision = evaluate_auto_price_reduction(ad_cfg, ad_file_relative, mode = AdUpdateStrategy.MODIFY) + + if not decision.enabled: + return False + + if decision.next_cycle is None: + return False + + if not decision.cycle_advanced: + return False + + LOG.info("Pending auto price reduction detected for ad [%s]", ad_file_relative) + return True + + def _log_auto_price_reduction_preview(ad_file_relative:str, decision:PriceReductionDecision) -> None: mode_label = _("publish") if decision.mode == AdUpdateStrategy.REPLACE else _("update") if not decision.enabled: diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml index f46a2de..aafe0bd 100644 --- a/src/kleinanzeigen_bot/resources/translations.de.yaml +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -374,6 +374,9 @@ kleinanzeigen_bot/__init__.py: ################################################# kleinanzeigen_bot/price_reduction.py: ################################################# + is_auto_price_reduction_due: + "Pending auto price reduction detected for ad [%s]": "Ausstehende automatische Preisreduzierung für Anzeige [%s] erkannt" + _log_auto_price_reduction_preview: "publish": "veröffentlichen" "update": "aktualisieren" diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index a8f5b23..96656d3 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -5485,6 +5485,178 @@ class TestKleinanzeigenBotChangedAds: # The changed ad should be loaded with 'due' selector because it's due for republication assert len(ads_to_publish) == 1 + def test_load_ads_with_changed_selector_and_pending_price_reduction(self, test_bot_config:Config, base_ad_config:dict[str, Any]) -> None: + """Test that 'changed' selector also loads ads with pending auto price reductions.""" + test_bot = KleinanzeigenBot() + test_bot.ads_selector = "changed" + test_bot.command = "update" + test_bot.config = test_bot_config.with_values({"ad_defaults": {"description": {"prefix": "", "suffix": ""}}}) + + # Create an ad with auto_price_reduction configured and ready to trigger + ad_cfg = Ad.model_validate( + base_ad_config + | { + "id": "12345", + "title": "Ad With Price Reduction", + "updated_on": "2024-01-01T00:00:00", + "created_on": "2024-01-01T00:00:00", + "price": 100, + "price_reduction_count": 0, + "repost_count": 1, + "active": True, + "auto_price_reduction": { + "enabled": True, + "on_update": True, + "strategy": "FIXED", + "amount": 10, + "min_price": 1, + "delay_days": 0, + "delay_reposts": 0, + }, + } + ) + + # Store the content hash so __check_ad_changed sees no change + ad_dict = ad_cfg.model_dump() + ad_dict["content_hash"] = ad_cfg.update_content_hash().content_hash + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + ad_dir = temp_path / "ads" + ad_dir.mkdir() + + dicts.save_dict(ad_dir / "ad_with_reduction.yaml", ad_dict) + + test_bot.config_file_path = str(temp_path / "config.yaml") + test_bot.config.ad_files = ["ads/*.yaml"] + + with patch( + "kleinanzeigen_bot.utils.dicts.load_dict", + side_effect = [ + ad_dict, # First call returns the ad + {}, # Second call for ad_fields.yaml + ], + ): + ads_to_publish = test_bot.load_ads() + + # The ad should be loaded because a price reduction is pending + assert len(ads_to_publish) == 1 + assert ads_to_publish[0][1].title == "Ad With Price Reduction" + + def test_load_ads_with_changed_selector_no_price_reduction_when_not_configured(self, test_bot_config:Config, base_ad_config:dict[str, Any]) -> None: + """Test that 'changed' selector does not load an unchanged ad when auto_price_reduction is disabled.""" + test_bot = KleinanzeigenBot() + test_bot.ads_selector = "changed" + test_bot.command = "update" + test_bot.config = test_bot_config.with_values({"ad_defaults": {"description": {"prefix": "", "suffix": ""}}}) + + # Create an ad with auto_price_reduction disabled + ad_cfg = Ad.model_validate( + base_ad_config + | { + "id": "12345", + "title": "Unchanged Ad", + "updated_on": "2024-01-01T00:00:00", + "created_on": "2024-01-01T00:00:00", + "price": 100, + "repost_count": 1, + "active": True, + "auto_price_reduction": { + "enabled": False, + "on_update": True, + "strategy": "FIXED", + "amount": 10, + "min_price": 1, + "delay_days": 0, + "delay_reposts": 0, + }, + } + ) + + # Store the content hash so __check_ad_changed sees no change + ad_dict = ad_cfg.model_dump() + ad_dict["content_hash"] = ad_cfg.update_content_hash().content_hash + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + ad_dir = temp_path / "ads" + ad_dir.mkdir() + + dicts.save_dict(ad_dir / "unchanged_ad.yaml", ad_dict) + + test_bot.config_file_path = str(temp_path / "config.yaml") + test_bot.config.ad_files = ["ads/*.yaml"] + + with patch( + "kleinanzeigen_bot.utils.dicts.load_dict", + side_effect = [ + ad_dict, # First call returns the ad + {}, # Second call for ad_fields.yaml + ], + ): + ads_to_publish = test_bot.load_ads() + + # The ad should NOT be loaded because it's unchanged and auto_price_reduction is disabled + assert len(ads_to_publish) == 0 + + def test_load_ads_with_changed_selector_does_not_include_price_reduction_in_publish_mode( + self, test_bot_config:Config, base_ad_config:dict[str, Any]) -> None: + """Test that 'changed' selector in publish mode skips unchanged ads even when price reduction is pending.""" + test_bot = KleinanzeigenBot() + test_bot.ads_selector = "changed" + test_bot.command = "publish" + test_bot.config = test_bot_config.with_values({"ad_defaults": {"description": {"prefix": "", "suffix": ""}}}) + + # Create an ad with auto_price_reduction configured and ready to trigger + ad_cfg = Ad.model_validate( + base_ad_config + | { + "id": "12345", + "title": "Ad With Price Reduction", + "updated_on": "2024-01-01T00:00:00", + "created_on": "2024-01-01T00:00:00", + "price": 100, + "price_reduction_count": 0, + "repost_count": 1, + "active": True, + "auto_price_reduction": { + "enabled": True, + "on_update": True, + "strategy": "FIXED", + "amount": 10, + "min_price": 1, + "delay_days": 0, + "delay_reposts": 0, + }, + } + ) + + # Store the content hash so __check_ad_changed sees no change + ad_dict = ad_cfg.model_dump() + ad_dict["content_hash"] = ad_cfg.update_content_hash().content_hash + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + ad_dir = temp_path / "ads" + ad_dir.mkdir() + + dicts.save_dict(ad_dir / "ad_with_reduction.yaml", ad_dict) + + test_bot.config_file_path = str(temp_path / "config.yaml") + test_bot.config.ad_files = ["ads/*.yaml"] + + with patch( + "kleinanzeigen_bot.utils.dicts.load_dict", + side_effect = [ + ad_dict, # First call returns the ad + {}, # Second call for ad_fields.yaml + ], + ): + ads_to_publish = test_bot.load_ads() + + # The ad should NOT be loaded because price reduction is only applied in update mode + assert len(ads_to_publish) == 0 + def test_file_logger_writes_message(tmp_path:Path, caplog:pytest.LogCaptureFixture) -> None: """ diff --git a/tests/unit/test_price_reduction.py b/tests/unit/test_price_reduction.py index b819599..c0f1b36 100644 --- a/tests/unit/test_price_reduction.py +++ b/tests/unit/test_price_reduction.py @@ -777,6 +777,192 @@ def test_apply_with_null_config_does_not_crash( assert ad_cfg.price == 100 # unchanged, no reduction to apply +@pytest.mark.unit +def test_is_auto_price_reduction_due_is_false_when_no_ad_id() -> None: + """is_auto_price_reduction_due returns False when ad has no id (new ad).""" + ad_cfg = SimpleNamespace( + id = None, + price = 100, + auto_price_reduction = None, + price_reduction_count = 0, + repost_count = 0, + updated_on = None, + created_on = None, + ) + assert kleinanzeigen_bot.is_auto_price_reduction_due(ad_cfg, "ad_new.yaml") is False # type: ignore[arg-type] + + +@pytest.mark.unit +def test_is_auto_price_reduction_due_is_false_when_not_enabled( + monkeypatch:pytest.MonkeyPatch, +) -> None: + """is_auto_price_reduction_due returns False when auto_price_reduction is not enabled.""" + ad_cfg = SimpleNamespace( + id = 12345, + price = 100, + auto_price_reduction = None, + price_reduction_count = 0, + repost_count = 0, + updated_on = None, + created_on = None, + ) + + monkeypatch.setattr( + "kleinanzeigen_bot.price_reduction.evaluate_auto_price_reduction", + lambda *args, **kwargs: SimpleNamespace( + enabled = False, + next_cycle = 1, + cycle_advanced = True, + ), + ) + + assert kleinanzeigen_bot.is_auto_price_reduction_due(ad_cfg, "ad_disabled.yaml") is False # type: ignore[arg-type] + + +@pytest.mark.unit +def test_is_auto_price_reduction_due_is_false_when_next_cycle_is_none( + monkeypatch:pytest.MonkeyPatch, +) -> None: + """is_auto_price_reduction_due returns False when next_cycle is None.""" + ad_cfg = SimpleNamespace( + id = 12345, + price = 100, + auto_price_reduction = None, + price_reduction_count = 0, + repost_count = 0, + updated_on = None, + created_on = None, + ) + + monkeypatch.setattr( + "kleinanzeigen_bot.price_reduction.evaluate_auto_price_reduction", + lambda *args, **kwargs: SimpleNamespace( + enabled = True, + next_cycle = None, + cycle_advanced = True, + ), + ) + + assert kleinanzeigen_bot.is_auto_price_reduction_due(ad_cfg, "ad_no_next_cycle.yaml") is False # type: ignore[arg-type] + + +@pytest.mark.unit +def test_is_auto_price_reduction_due_is_false_when_cycle_not_advanced( + monkeypatch:pytest.MonkeyPatch, +) -> None: + """is_auto_price_reduction_due returns False when cycle_advanced is False.""" + ad_cfg = SimpleNamespace( + id = 12345, + price = 100, + auto_price_reduction = None, + price_reduction_count = 0, + repost_count = 0, + updated_on = None, + created_on = None, + ) + + monkeypatch.setattr( + "kleinanzeigen_bot.price_reduction.evaluate_auto_price_reduction", + lambda *args, **kwargs: SimpleNamespace( + enabled = True, + next_cycle = 1, + cycle_advanced = False, + ), + ) + + assert kleinanzeigen_bot.is_auto_price_reduction_due(ad_cfg, "ad_no_cycle_advance.yaml") is False # type: ignore[arg-type] + + +@pytest.mark.unit +def test_is_auto_price_reduction_due_returns_true_when_reduction_due( + monkeypatch:pytest.MonkeyPatch, +) -> None: + """is_auto_price_reduction_due returns True when a reduction is actually due (enabled, next_cycle set, cycle_advanced).""" + ad_cfg = SimpleNamespace( + id = 12345, + price = 100, + auto_price_reduction = None, + price_reduction_count = 0, + repost_count = 0, + updated_on = None, + created_on = None, + ) + + monkeypatch.setattr( + "kleinanzeigen_bot.price_reduction.evaluate_auto_price_reduction", + lambda *args, **kwargs: SimpleNamespace( + enabled = True, + next_cycle = 1, + cycle_advanced = True, + ), + ) + + assert kleinanzeigen_bot.is_auto_price_reduction_due(ad_cfg, "ad_reduction_due.yaml") is True # type: ignore[arg-type] + + +@pytest.mark.unit +def test_is_price_reduction_due_returns_true_when_eligible_real( + monkeypatch:pytest.MonkeyPatch, +) -> None: + """is_auto_price_reduction_due returns True when eligible (exercises real evaluate_auto_price_reduction).""" + now_dt = datetime(2024, 6, 1, tzinfo = timezone.utc) + monkeypatch.setattr("kleinanzeigen_bot.misc.now", lambda: now_dt) + + price_cfg = _price_cfg(on_update = True, delay_days = 0) + ad_cfg = SimpleNamespace( + id = "123", + price = 100, + auto_price_reduction = price_cfg, + price_reduction_count = 0, + updated_on = datetime(2024, 1, 1, tzinfo = timezone.utc), + repost_count = 5, + ) + + assert kleinanzeigen_bot.is_auto_price_reduction_due(ad_cfg, "test.yaml") is True # type: ignore[arg-type] + + +@pytest.mark.unit +def test_is_price_reduction_due_returns_false_when_delay_not_satisfied_real( + monkeypatch:pytest.MonkeyPatch, +) -> None: + """is_auto_price_reduction_due returns False when day-delay is not satisfied (exercises real evaluate_auto_price_reduction).""" + now_dt = datetime(2024, 1, 1, 12, 0, 0, tzinfo = timezone.utc) + monkeypatch.setattr("kleinanzeigen_bot.misc.now", lambda: now_dt) + + price_cfg = _price_cfg(on_update = True, delay_days = 1) + ad_cfg = SimpleNamespace( + id = "123", + price = 100, + auto_price_reduction = price_cfg, + price_reduction_count = 0, + updated_on = datetime(2024, 1, 1, tzinfo = timezone.utc), + repost_count = 5, + ) + + assert kleinanzeigen_bot.is_auto_price_reduction_due(ad_cfg, "test.yaml") is False # type: ignore[arg-type] + + +@pytest.mark.unit +def test_is_price_reduction_due_returns_false_when_on_update_disabled_real( + monkeypatch:pytest.MonkeyPatch, +) -> None: + """is_auto_price_reduction_due returns False when on_update is False (exercises real evaluate_auto_price_reduction).""" + now_dt = datetime(2024, 6, 1, tzinfo = timezone.utc) + monkeypatch.setattr("kleinanzeigen_bot.misc.now", lambda: now_dt) + + price_cfg = _price_cfg(on_update = False, delay_days = 0) + ad_cfg = SimpleNamespace( + id = "123", + price = 100, + auto_price_reduction = price_cfg, + price_reduction_count = 0, + updated_on = datetime(2024, 1, 1, tzinfo = timezone.utc), + repost_count = 5, + ) + + assert kleinanzeigen_bot.is_auto_price_reduction_due(ad_cfg, "test.yaml") is False # type: ignore[arg-type] + + @pytest.mark.unit def test_evaluate_with_null_config_returns_disabled( ) -> None: