From 920ddf553304d240d2f1986bebe9c518d802ea7c Mon Sep 17 00:00:00 2001 From: Jens <1742418+1cu@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:31:58 +0100 Subject: [PATCH] feat: Add automatic price reduction on reposts (#691) --- README.md | 63 +- pyproject.toml | 3 +- schemas/ad.schema.json | 97 +++ schemas/config.schema.json | 75 +++ src/kleinanzeigen_bot/__init__.py | 185 +++++- src/kleinanzeigen_bot/model/ad_model.py | 154 ++++- src/kleinanzeigen_bot/model/config_model.py | 63 +- .../resources/translations.de.yaml | 29 + src/kleinanzeigen_bot/utils/pydantics.py | 12 +- tests/unit/test_ad_model.py | 374 +++++++++++- tests/unit/test_init.py | 162 +++++- tests/unit/test_price_reduction.py | 550 ++++++++++++++++++ tests/unit/test_pydantics.py | 8 + 13 files changed, 1753 insertions(+), 22 deletions(-) create mode 100644 tests/unit/test_price_reduction.py diff --git a/README.md b/README.md index a00b2d9..2a49720 100644 --- a/README.md +++ b/README.md @@ -384,8 +384,16 @@ description_suffix: # optional suffix to be added to the description overriding # or category ID (e.g. 161/278) category: # e.g. "Elektronik > Notebooks" -price: # without decimals, e.g. 75 +price: # price in euros; decimals allowed but will be rounded to nearest whole euro on processing (prefer whole euros for predictability) price_type: # one of: FIXED, NEGOTIABLE, GIVE_AWAY (default: NEGOTIABLE) +auto_price_reduction: + enabled: # true or false to enable automatic price reduction on reposts (default: false) + strategy: # "PERCENTAGE" or "FIXED" (required when enabled is true) + amount: # reduction amount; interpreted as percent for PERCENTAGE or currency units for FIXED (prefer whole euros for predictability) + min_price: # required when enabled is true; minimum price floor (use 0 for no lower bound, prefer whole euros for predictability) + delay_reposts: # number of reposts to wait before first reduction (default: 0) + delay_days: # number of days to wait after publication before reductions (default: 0) + # NOTE: All prices are rounded to whole euros after each reduction step. special_attributes: # haus_mieten.zimmer_d: value # Zimmer @@ -428,8 +436,61 @@ id: # the ID assigned by kleinanzeigen.de created_on: # ISO timestamp when the ad was first published updated_on: # ISO timestamp when the ad was last published content_hash: # hash of the ad content, used to detect changes +repost_count: # how often the ad has been (re)published; used for automatic price reductions ``` +#### Automatic price reduction on reposts + +When `auto_price_reduction.enabled` is set to `true`, the bot lowers the configured `price` every time the ad is reposted. The starting point for the calculation is always the base price from your ad file (the value of `price`), ensuring the first publication uses the unchanged amount. For each repost the bot subtracts either a percentage of the previously published price (strategy: PERCENTAGE) or a fixed amount (strategy: FIXED) and clamps the result to `min_price`. + +**Important:** Price reductions only apply when using the `publish` command (which deletes the old ad and creates a new one). Using the `update` command to modify ad content does NOT trigger price reductions or increment `repost_count`. + +`repost_count` is tracked for every ad (and persisted inside the corresponding `ad_*.yaml`) so reductions continue across runs. + +`min_price` is required whenever `enabled` is `true` and must be less than or equal to `price`; this makes an explicit floor (including `0`) mandatory. If `min_price` equals the current price, the bot will log a warning and perform no reduction. + +**Note:** `repost_count` and price reduction counters are only incremented and persisted after a successful publish. Failed publish attempts do not advance the counters. + +**PERCENTAGE strategy example:** + +```yaml +price: 150 +price_type: FIXED +auto_price_reduction: + enabled: true + strategy: PERCENTAGE + amount: 10 + min_price: 90 + delay_reposts: 0 + delay_days: 0 +``` + +This posts the ad at 150 € the first time, then 135 € (−10%), 122 € (−10%), 110 € (−10%), 99 € (−10%), and stops decreasing at 90 €. + +**Note:** The bot applies commercial rounding (ROUND_HALF_UP) to full euros after each reduction step. For example, 121.5 rounds to 122, and 109.8 rounds to 110. This step-wise rounding affects the final price progression, especially for percentage-based reductions. + +**FIXED strategy example:** + +```yaml +price: 150 +price_type: FIXED +auto_price_reduction: + enabled: true + strategy: FIXED + amount: 15 + min_price: 90 + delay_reposts: 0 + delay_days: 0 +``` + +This posts the ad at 150 € the first time, then 135 € (−15 €), 120 € (−15 €), 105 € (−15 €), and stops decreasing at 90 €. + +**Note on `delay_days` behavior:** The `delay_days` parameter counts complete 24-hour periods (whole days) since the ad was published. For example, if `delay_days: 7` and the ad was published 6 days and 23 hours ago, the reduction will not yet apply. This ensures predictable behavior and avoids partial-day ambiguity. + +Set `auto_price_reduction.enabled: false` (or omit the entire `auto_price_reduction` section) to keep the existing behaviour—prices stay fixed and `repost_count` only acts as tracked metadata for future changes. + +You can configure `auto_price_reduction` once under `ad_defaults` in `config.yaml`. The `min_price` can be set there or overridden per ad file as needed. + ### 3) Description Prefix and Suffix You can add prefix and suffix text to your ad descriptions in two ways: diff --git a/pyproject.toml b/pyproject.toml index 83025be..e82cad6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -353,7 +353,8 @@ markers = [ "slow: marks a test as long running", "smoke: marks a test as a high-level smoke test (critical path, no mocks)", "itest: marks a test as an integration test (i.e. a test with external dependencies)", - "asyncio: mark test as async" + "asyncio: mark test as async", + "unit: marks a test as a unit test" ] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" diff --git a/schemas/ad.schema.json b/schemas/ad.schema.json index a2369a4..47f5563 100644 --- a/schemas/ad.schema.json +++ b/schemas/ad.schema.json @@ -1,5 +1,76 @@ { "$defs": { + "AutoPriceReductionConfig": { + "properties": { + "enabled": { + "default": false, + "description": "automatically lower the price of reposted ads", + "title": "Enabled", + "type": "boolean" + }, + "strategy": { + "anyOf": [ + { + "enum": [ + "FIXED", + "PERCENTAGE" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "PERCENTAGE reduces by a percentage of the previous price, FIXED reduces by a fixed amount", + "title": "Strategy" + }, + "amount": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "magnitude of the reduction; interpreted as percent for PERCENTAGE or currency units for FIXED", + "title": "Amount" + }, + "min_price": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "required when enabled is true; minimum price floor (use 0 for no lower bound)", + "title": "Min Price" + }, + "delay_reposts": { + "default": 0, + "description": "number of reposts to wait before applying the first automatic price reduction", + "minimum": 0, + "title": "Delay Reposts", + "type": "integer" + }, + "delay_days": { + "default": 0, + "description": "number of days to wait after publication before applying automatic price reductions", + "minimum": 0, + "title": "Delay Days", + "type": "integer" + } + }, + "title": "AutoPriceReductionConfig", + "type": "object" + }, "ContactPartial": { "properties": { "name": { @@ -181,6 +252,32 @@ "default": null, "title": "Price Type" }, + "auto_price_reduction": { + "anyOf": [ + { + "$ref": "#/$defs/AutoPriceReductionConfig" + }, + { + "type": "null" + } + ], + "default": null, + "description": "automatic price reduction configuration" + }, + "repost_count": { + "default": 0, + "description": "number of successful publications for this ad (persisted between runs)", + "minimum": 0, + "title": "Repost Count", + "type": "integer" + }, + "price_reduction_count": { + "default": 0, + "description": "internal counter: number of automatic price reductions already applied", + "minimum": 0, + "title": "Price Reduction Count", + "type": "integer" + }, "shipping_type": { "anyOf": [ { diff --git a/schemas/config.schema.json b/schemas/config.schema.json index d432e15..aabd924 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -64,6 +64,10 @@ "title": "Price Type", "type": "string" }, + "auto_price_reduction": { + "$ref": "#/$defs/AutoPriceReductionConfig", + "description": "automatic price reduction configuration" + }, "shipping_type": { "default": "SHIPPING", "enum": [ @@ -107,6 +111,77 @@ "title": "AdDefaults", "type": "object" }, + "AutoPriceReductionConfig": { + "properties": { + "enabled": { + "default": false, + "description": "automatically lower the price of reposted ads", + "title": "Enabled", + "type": "boolean" + }, + "strategy": { + "anyOf": [ + { + "enum": [ + "FIXED", + "PERCENTAGE" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "PERCENTAGE reduces by a percentage of the previous price, FIXED reduces by a fixed amount", + "title": "Strategy" + }, + "amount": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "magnitude of the reduction; interpreted as percent for PERCENTAGE or currency units for FIXED", + "title": "Amount" + }, + "min_price": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "required when enabled is true; minimum price floor (use 0 for no lower bound)", + "title": "Min Price" + }, + "delay_reposts": { + "default": 0, + "description": "number of reposts to wait before applying the first automatic price reduction", + "minimum": 0, + "title": "Delay Reposts", + "type": "integer" + }, + "delay_days": { + "default": 0, + "description": "number of days to wait after publication before applying automatic price reductions", + "minimum": 0, + "title": "Delay Days", + "type": "integer" + } + }, + "title": "AutoPriceReductionConfig", + "type": "object" + }, "BrowserConfig": { "properties": { "arguments": { diff --git a/src/kleinanzeigen_bot/__init__.py b/src/kleinanzeigen_bot/__init__.py index d7b74e0..5b71ba0 100644 --- a/src/kleinanzeigen_bot/__init__.py +++ b/src/kleinanzeigen_bot/__init__.py @@ -5,6 +5,7 @@ import atexit, enum, json, os, re, signal, sys, textwrap # isort: skip import getopt # pylint: disable=deprecated-module import urllib.parse as urllib_parse from gettext import gettext as _ +from pathlib import Path from typing import Any, Final import certifi, colorama, nodriver # isort: skip @@ -13,7 +14,7 @@ from wcmatch import glob from . import extract, resources from ._version import __version__ -from .model.ad_model import MAX_DESCRIPTION_LENGTH, Ad, AdPartial +from .model.ad_model import MAX_DESCRIPTION_LENGTH, Ad, AdPartial, calculate_auto_price from .model.config_model import Config from .update_checker import UpdateChecker from .utils import dicts, error_handlers, loggers, misc @@ -36,6 +37,145 @@ class AdUpdateStrategy(enum.Enum): MODIFY = enum.auto() +def _repost_cycle_ready(ad_cfg:Ad, ad_file_relative:str) -> bool: + """ + Check if the repost cycle delay has been satisfied. + + :param ad_cfg: The ad configuration + :param ad_file_relative: Relative path to the ad file for logging + :return: True if ready to apply price reduction, False otherwise + """ + total_reposts = ad_cfg.repost_count or 0 + delay_reposts = ad_cfg.auto_price_reduction.delay_reposts + applied_cycles = ad_cfg.price_reduction_count or 0 + eligible_cycles = max(total_reposts - delay_reposts, 0) + + if total_reposts <= delay_reposts: + remaining = (delay_reposts + 1) - total_reposts + LOG.info( + _("Auto price reduction delayed for [%s]: waiting %s more reposts (completed %s, applied %s reductions)"), + ad_file_relative, + max(remaining, 1), # Clamp to 1 to avoid showing "0 more reposts" when at threshold + total_reposts, + applied_cycles + ) + return False + + if eligible_cycles <= applied_cycles: + LOG.debug( + _("Auto price reduction already applied for [%s]: %s reductions match %s eligible reposts"), + ad_file_relative, + applied_cycles, + eligible_cycles + ) + return False + + return True + + +def _day_delay_elapsed(ad_cfg:Ad, ad_file_relative:str) -> bool: + """ + Check if the day delay has elapsed since the ad was last published. + + :param ad_cfg: The ad configuration + :param ad_file_relative: Relative path to the ad file for logging + :return: True if the delay has elapsed, False otherwise + """ + delay_days = ad_cfg.auto_price_reduction.delay_days + if delay_days == 0: + return True + + reference = ad_cfg.updated_on or ad_cfg.created_on + if not reference: + LOG.info( + _("Auto price reduction delayed for [%s]: waiting %s days but publish timestamp missing"), + ad_file_relative, + delay_days + ) + return False + + # Note: .days truncates to whole days (e.g., 1.9 days -> 1 day) + # This is intentional: delays count complete 24-hour periods since publish + # Both misc.now() and stored timestamps use UTC (via misc.now()), ensuring consistent calculations + elapsed_days = (misc.now() - reference).days + if elapsed_days < delay_days: + LOG.info( + _("Auto price reduction delayed for [%s]: waiting %s days (elapsed %s)"), + ad_file_relative, + delay_days, + elapsed_days + ) + return False + + return True + + +def apply_auto_price_reduction(ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], ad_file_relative:str) -> None: + """ + Apply automatic price reduction to an ad based on repost count and configuration. + + This function modifies ad_cfg in-place, updating the price and price_reduction_count + fields when a reduction is applicable. + + :param ad_cfg: The ad configuration to potentially modify + :param _ad_cfg_orig: The original ad configuration (unused, kept for compatibility) + :param ad_file_relative: Relative path to the ad file for logging + """ + if not ad_cfg.auto_price_reduction.enabled: + return + + base_price = ad_cfg.price + if base_price is None: + LOG.warning(_("Auto price reduction is enabled for [%s] but no price is configured."), ad_file_relative) + return + + if ad_cfg.auto_price_reduction.min_price is not None and ad_cfg.auto_price_reduction.min_price == base_price: + LOG.warning( + _("Auto price reduction is enabled for [%s] but min_price equals price (%s) - no reductions will occur."), + ad_file_relative, + base_price + ) + return + + if not _repost_cycle_ready(ad_cfg, ad_file_relative): + return + + if not _day_delay_elapsed(ad_cfg, ad_file_relative): + return + + applied_cycles = ad_cfg.price_reduction_count or 0 + next_cycle = applied_cycles + 1 + + effective_price = calculate_auto_price( + base_price = base_price, + auto_price_reduction = ad_cfg.auto_price_reduction, + target_reduction_cycle = next_cycle + ) + + if effective_price is None: + return + + if effective_price == base_price: + # Still increment counter so small fractional reductions can accumulate over multiple cycles + ad_cfg.price_reduction_count = next_cycle + LOG.info( + _("Auto price reduction kept price %s after attempting %s reduction cycles"), + effective_price, + next_cycle + ) + return + + LOG.info( + _("Auto price reduction applied: %s -> %s after %s reduction cycles"), + base_price, + effective_price, + next_cycle + ) + ad_cfg.price = effective_price + ad_cfg.price_reduction_count = next_cycle + # Note: price_reduction_count is persisted to ad_cfg_orig only after successful publish + + class KleinanzeigenBot(WebScrapingMixin): def __init__(self) -> None: @@ -364,7 +504,11 @@ class KleinanzeigenBot(WebScrapingMixin): dicts.save_dict( self.config_file_path, default_config.model_dump(exclude_none = True, exclude = {"ad_defaults": {"description"}}), - header = "# yaml-language-server: $schema=https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/refs/heads/main/schemas/config.schema.json" + header = ( + "# yaml-language-server: $schema=" + "https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot" + "/refs/heads/main/schemas/config.schema.json" + ) ) def load_config(self) -> None: @@ -571,6 +715,18 @@ class KleinanzeigenBot(WebScrapingMixin): def load_ad(self, ad_cfg_orig:dict[str, Any]) -> Ad: return AdPartial.model_validate(ad_cfg_orig).to_ad(self.config.ad_defaults) + def __apply_auto_price_reduction(self, ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], ad_file_relative:str) -> None: + """Delegate to the module-level function.""" + apply_auto_price_reduction(ad_cfg, _ad_cfg_orig, ad_file_relative) + + def __repost_cycle_ready(self, ad_cfg:Ad, ad_file_relative:str) -> bool: + """Delegate to the module-level function.""" + return _repost_cycle_ready(ad_cfg, ad_file_relative) + + def __day_delay_elapsed(self, ad_cfg:Ad, ad_file_relative:str) -> bool: + """Delegate to the module-level function.""" + return _day_delay_elapsed(ad_cfg, ad_file_relative) + async def check_and_wait_for_captcha(self, *, is_login_page:bool = True) -> None: try: captcha_timeout = self._timeout("captcha_detection") @@ -775,6 +931,15 @@ class KleinanzeigenBot(WebScrapingMixin): if self.config.publishing.delete_old_ads == "BEFORE_PUBLISH" and not self.keep_old_ads: await self.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title = self.config.publishing.delete_old_ads_by_title) + # Apply auto price reduction only for REPLACE operations (actual reposts) + # This ensures price reductions only happen on republish, not on UPDATE + try: + ad_file_relative = str(Path(ad_file).relative_to(Path(self.config_file_path).parent)) + except ValueError: + # On Windows, relative_to fails when paths are on different drives + ad_file_relative = ad_file + self.__apply_auto_price_reduction(ad_cfg, ad_cfg_orig, ad_file_relative) + LOG.info("Publishing ad '%s'...", ad_cfg.title) await self.web_open(f"{self.root_url}/p-anzeige-aufgeben-schritt2.html") else: @@ -979,6 +1144,22 @@ class KleinanzeigenBot(WebScrapingMixin): if not ad_cfg.created_on and not ad_cfg.id: ad_cfg_orig["created_on"] = ad_cfg_orig["updated_on"] + # Increment repost_count and persist price_reduction_count only for REPLACE operations (actual reposts) + # This ensures counters only advance on republish, not on UPDATE + if mode == AdUpdateStrategy.REPLACE: + # Increment repost_count after successful publish + # Note: This happens AFTER publish, so price reduction logic (which runs before publish) + # sees the count from the PREVIOUS run. This is intentional: the first publish uses + # repost_count=0 (no reduction), the second publish uses repost_count=1 (first reduction), etc. + current_reposts = int(ad_cfg_orig.get("repost_count", ad_cfg.repost_count or 0)) + ad_cfg_orig["repost_count"] = current_reposts + 1 + ad_cfg.repost_count = ad_cfg_orig["repost_count"] + + # Persist price_reduction_count after successful publish + # This ensures failed publishes don't incorrectly increment the reduction counter + if ad_cfg.price_reduction_count is not None and ad_cfg.price_reduction_count > 0: + ad_cfg_orig["price_reduction_count"] = ad_cfg.price_reduction_count + if mode == AdUpdateStrategy.REPLACE: LOG.info(" -> SUCCESS: ad published with ID %s", ad_id) else: diff --git a/src/kleinanzeigen_bot/model/ad_model.py b/src/kleinanzeigen_bot/model/ad_model.py index 102bb75..1ec8b91 100644 --- a/src/kleinanzeigen_bot/model/ad_model.py +++ b/src/kleinanzeigen_bot/model/ad_model.py @@ -4,18 +4,22 @@ from __future__ import annotations import hashlib, json # isort: skip +from collections.abc import Mapping, Sequence from datetime import datetime # noqa: TC003 Move import into a type-checking block -from typing import Annotated, Any, Dict, Final, List, Literal, Mapping, Sequence +from decimal import ROUND_CEILING, ROUND_HALF_UP, Decimal +from gettext import gettext as _ +from typing import Annotated, Any, Final, Literal from pydantic import AfterValidator, Field, field_validator, model_validator from typing_extensions import Self -from kleinanzeigen_bot.model.config_model import AdDefaults # noqa: TC001 Move application import into a type-checking block +from kleinanzeigen_bot.model.config_model import AdDefaults, AutoPriceReductionConfig # noqa: TC001 Move application import into a type-checking block from kleinanzeigen_bot.utils import dicts from kleinanzeigen_bot.utils.misc import parse_datetime, parse_decimal from kleinanzeigen_bot.utils.pydantics import ContextualModel MAX_DESCRIPTION_LENGTH:Final[int] = 4000 +EURO_PRECISION:Final[Decimal] = Decimal("1") def _OPTIONAL() -> Any: @@ -61,6 +65,45 @@ def _validate_shipping_option_item(v:str) -> str: ShippingOption = Annotated[str, AfterValidator(_validate_shipping_option_item)] +def _validate_auto_price_reduction_constraints( + price:int | None, + auto_price_reduction:AutoPriceReductionConfig | dict[str, Any] | None +) -> None: + """ + Validate auto_price_reduction configuration constraints. + + Raises ValueError if: + - auto_price_reduction is enabled but price is None + - min_price exceeds price + """ + if not auto_price_reduction: + return + + # Handle both dict (from before validation) and AutoPriceReductionConfig (after validation) + if isinstance(auto_price_reduction, dict): + enabled = auto_price_reduction.get("enabled", False) + min_price = auto_price_reduction.get("min_price") + else: + enabled = auto_price_reduction.enabled + min_price = auto_price_reduction.min_price + + if not enabled: + return + + if price is None: + raise ValueError(_("price must be specified when auto_price_reduction is enabled")) + + if min_price is not None: + try: + min_price_dec = Decimal(str(min_price)) + price_dec = Decimal(str(price)) + except Exception: + # Let Pydantic's type validation surface the underlying issue + return + if min_price_dec > price_dec: + raise ValueError(_("min_price must not exceed price")) + + class AdPartial(ContextualModel): active:bool | None = _OPTIONAL() type:Literal["OFFER", "WANTED"] | None = _OPTIONAL() @@ -69,14 +112,28 @@ class AdPartial(ContextualModel): description_prefix:str | None = _OPTIONAL() description_suffix:str | None = _OPTIONAL() category:str - special_attributes:Dict[str, str] | None = _OPTIONAL() + special_attributes:dict[str, str] | None = _OPTIONAL() price:int | None = _OPTIONAL() price_type:Literal["FIXED", "NEGOTIABLE", "GIVE_AWAY", "NOT_APPLICABLE"] | None = _OPTIONAL() + auto_price_reduction:AutoPriceReductionConfig | None = Field( + default = None, + description = "automatic price reduction configuration" + ) + repost_count:int = Field( + default = 0, + ge = 0, + description = "number of successful publications for this ad (persisted between runs)" + ) + price_reduction_count:int = Field( + default = 0, + ge = 0, + description = "internal counter: number of automatic price reductions already applied" + ) shipping_type:Literal["PICKUP", "SHIPPING", "NOT_APPLICABLE"] | None = _OPTIONAL() shipping_costs:float | None = _OPTIONAL() - shipping_options:List[ShippingOption] | None = _OPTIONAL() + shipping_options:list[ShippingOption] | None = _OPTIONAL() sell_directly:bool | None = _OPTIONAL() - images:List[str] | None = _OPTIONAL() + images:list[str] | None = _OPTIONAL() contact:ContactPartial | None = _OPTIONAL() republication_interval:int | None = _OPTIONAL() @@ -106,13 +163,19 @@ class AdPartial(ContextualModel): @model_validator(mode = "before") @classmethod - def _validate_price_and_price_type(cls, values:Dict[str, Any]) -> Dict[str, Any]: + def _validate_price_and_price_type(cls, values:dict[str, Any]) -> dict[str, Any]: price_type = values.get("price_type") price = values.get("price") + auto_price_reduction = values.get("auto_price_reduction") + if price_type == "GIVE_AWAY" and price is not None: raise ValueError("price must not be specified when price_type is GIVE_AWAY") if price_type == "FIXED" and price is None: raise ValueError("price is required when price_type is FIXED") + + # Validate auto_price_reduction configuration + _validate_auto_price_reduction_constraints(price, auto_price_reduction) + return values def update_content_hash(self) -> Self: @@ -120,7 +183,14 @@ class AdPartial(ContextualModel): # 1) Dump to a plain dict, excluding the metadata fields: raw = self.model_dump( - exclude = {"id", "created_on", "updated_on", "content_hash"}, + exclude = { + "id", + "created_on", + "updated_on", + "content_hash", + "repost_count", + "price_reduction_count", + }, exclude_none = True, exclude_unset = True, ) @@ -162,11 +232,70 @@ class AdPartial(ContextualModel): target = ad_cfg, defaults = ad_defaults.model_dump(), ignore = lambda k, _: k == "description", # ignore legacy global description config - override = lambda _, v: not isinstance(v, list) and v in {None, ""} # noqa: PLC1901 can be simplified + override = lambda _, v: ( + not isinstance(v, list) and (v is None or (isinstance(v, str) and v == "")) # noqa: PLC1901 + ) ) + # Ensure internal counters are integers (not user-configurable) + if not isinstance(ad_cfg.get("price_reduction_count"), int): + ad_cfg["price_reduction_count"] = 0 + if not isinstance(ad_cfg.get("repost_count"), int): + ad_cfg["repost_count"] = 0 return Ad.model_validate(ad_cfg) +def calculate_auto_price( + *, + base_price:int | float | None, + auto_price_reduction:AutoPriceReductionConfig | None, + target_reduction_cycle:int +) -> int | None: + """ + Calculate the effective price for the current run using commercial rounding. + + Args: + base_price: original configured price used as the starting point. + auto_price_reduction: reduction configuration (enabled, strategy, amount, min_price, delays). + target_reduction_cycle: which reduction cycle to calculate the price for (0 = no reduction, 1 = first reduction, etc.). + + Percentage reductions apply to the current price each cycle (compounded). Each reduction step is rounded + to full euros (commercial rounding with ROUND_HALF_UP) before the next reduction is applied. + Returns an int representing whole euros, or None when base_price is None. + """ + if base_price is None: + return None + + price = Decimal(str(base_price)) + + if not auto_price_reduction or not auto_price_reduction.enabled or target_reduction_cycle <= 0: + return int(price.quantize(EURO_PRECISION, rounding = ROUND_HALF_UP)) + + if auto_price_reduction.strategy is None or auto_price_reduction.amount is None: + return int(price.quantize(EURO_PRECISION, rounding = ROUND_HALF_UP)) + + if auto_price_reduction.min_price is None: + raise ValueError(_("min_price must be specified when auto_price_reduction is enabled")) + + # Prices are published as whole euros; ensure the configured floor cannot be undercut by int() conversion. + price_floor = Decimal(str(auto_price_reduction.min_price)).quantize(EURO_PRECISION, rounding = ROUND_CEILING) + repost_cycles = target_reduction_cycle + + for _cycle in range(repost_cycles): + reduction_value = ( + price * Decimal(str(auto_price_reduction.amount)) / Decimal("100") + if auto_price_reduction.strategy == "PERCENTAGE" + else Decimal(str(auto_price_reduction.amount)) + ) + price -= reduction_value + # Commercial rounding: round to full euros after each reduction step + price = price.quantize(EURO_PRECISION, rounding = ROUND_HALF_UP) + if price <= price_floor: + price = price_floor + break + + return int(price) + + # pyright: reportGeneralTypeIssues=false, reportIncompatibleVariableOverride=false class Contact(ContactPartial): name:str @@ -183,3 +312,12 @@ class Ad(AdPartial): sell_directly:bool contact:Contact republication_interval:int + auto_price_reduction:AutoPriceReductionConfig = Field(default_factory = AutoPriceReductionConfig) + price_reduction_count:int = 0 + + @model_validator(mode = "after") + def _validate_auto_price_config(self) -> "Ad": + # Validate the final Ad object after merging with defaults + # This ensures the merged configuration is valid even if raw YAML had None values + _validate_auto_price_reduction_constraints(self.price, self.auto_price_reduction) + return self diff --git a/src/kleinanzeigen_bot/model/config_model.py b/src/kleinanzeigen_bot/model/config_model.py index 5506f14..e1ae71c 100644 --- a/src/kleinanzeigen_bot/model/config_model.py +++ b/src/kleinanzeigen_bot/model/config_model.py @@ -4,7 +4,8 @@ from __future__ import annotations import copy -from typing import Annotated, Any, List, Literal +from gettext import gettext as _ +from typing import Annotated, Any, Final, Literal from pydantic import AfterValidator, Field, model_validator from typing_extensions import deprecated @@ -14,6 +15,52 @@ from kleinanzeigen_bot.utils import dicts from kleinanzeigen_bot.utils.misc import get_attr from kleinanzeigen_bot.utils.pydantics import ContextualModel +_MAX_PERCENTAGE:Final[int] = 100 + + +class AutoPriceReductionConfig(ContextualModel): + enabled:bool = Field( + default = False, + description = "automatically lower the price of reposted ads" + ) + strategy:Literal["FIXED", "PERCENTAGE"] | None = Field( + default = None, + description = "PERCENTAGE reduces by a percentage of the previous price, FIXED reduces by a fixed amount" + ) + amount:float | None = Field( + default = None, + gt = 0, + description = "magnitude of the reduction; interpreted as percent for PERCENTAGE or currency units for FIXED" + ) + min_price:float | None = Field( + default = None, + ge = 0, + description = "required when enabled is true; minimum price floor (use 0 for no lower bound)" + ) + delay_reposts:int = Field( + default = 0, + ge = 0, + description = "number of reposts to wait before applying the first automatic price reduction" + ) + delay_days:int = Field( + default = 0, + ge = 0, + description = "number of days to wait after publication before applying automatic price reductions" + ) + + @model_validator(mode = "after") + def _validate_config(self) -> "AutoPriceReductionConfig": + if self.enabled: + if self.strategy is None: + raise ValueError(_("strategy must be specified when auto_price_reduction is enabled")) + if self.amount is None: + raise ValueError(_("amount must be specified when auto_price_reduction is enabled")) + if self.min_price is None: + raise ValueError(_("min_price must be specified when auto_price_reduction is enabled")) + if self.strategy == "PERCENTAGE" and self.amount > _MAX_PERCENTAGE: + raise ValueError(_("Percentage reduction amount must not exceed %s") % _MAX_PERCENTAGE) + return self + class ContactDefaults(ContextualModel): name:str | None = None @@ -35,9 +82,13 @@ class AdDefaults(ContextualModel): description_prefix:str | None = Field(default = None, description = "prefix for the ad description") description_suffix:str | None = Field(default = None, description = " suffix for the ad description") price_type:Literal["FIXED", "NEGOTIABLE", "GIVE_AWAY", "NOT_APPLICABLE"] = "NEGOTIABLE" + auto_price_reduction:AutoPriceReductionConfig = Field( + default_factory = AutoPriceReductionConfig, + description = "automatic price reduction configuration" + ) shipping_type:Literal["PICKUP", "SHIPPING", "NOT_APPLICABLE"] = "SHIPPING" sell_directly:bool = Field(default = False, description = "requires shipping_type SHIPPING to take effect") - images:List[str] | None = Field(default = None) + images:list[str] | None = Field(default = None) contact:ContactDefaults = Field(default_factory = ContactDefaults) republication_interval:int = 7 @@ -62,7 +113,7 @@ class DownloadConfig(ContextualModel): default = False, description = "if true, all shipping options matching the package size will be included" ) - excluded_shipping_options:List[str] = Field( + excluded_shipping_options:list[str] = Field( default_factory = list, description = "list of shipping options to exclude, e.g. ['DHL_2', 'DHL_5']" ) @@ -79,7 +130,7 @@ class DownloadConfig(ContextualModel): class BrowserConfig(ContextualModel): - arguments:List[str] = Field( + arguments:list[str] = Field( default_factory = lambda: ["--user-data-dir=.temp/browser-profile"], description = "See https://peter.sh/experiments/chromium-command-line-switches/" ) @@ -87,7 +138,7 @@ class BrowserConfig(ContextualModel): default = None, description = "path to custom browser executable, if not specified will be looked up on PATH" ) - extensions:List[str] = Field( + extensions:list[str] = Field( default_factory = list, description = "a list of .crx extension files to be loaded" ) @@ -175,7 +226,7 @@ GlobPattern = Annotated[str, AfterValidator(_validate_glob_pattern)] class Config(ContextualModel): - ad_files:List[GlobPattern] = Field( + ad_files:list[GlobPattern] = Field( default_factory = lambda: ["./**/ad_*.{json,yml,yaml}"], min_items = 1, description = """ diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml index b91b8c4..31d4d8b 100644 --- a/src/kleinanzeigen_bot/resources/translations.de.yaml +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -96,6 +96,17 @@ kleinanzeigen_bot/__init__.py: "Skipping because ad is reserved": "Überspringen, da Anzeige reserviert ist" "DONE: (Re-)published %s": "FERTIG: %s (erneut) veröffentlicht" "ad": "Anzeige" + apply_auto_price_reduction: + "Auto price reduction is enabled for [%s] but no price is configured.": "Automatische Preisreduzierung ist für [%s] aktiviert, aber es wurde kein Preis konfiguriert." + "Auto price reduction is enabled for [%s] but min_price equals price (%s) - no reductions will occur.": "Automatische Preisreduzierung ist für [%s] aktiviert, aber min_price entspricht dem Preis (%s) - es werden keine Reduktionen auftreten." + "Auto price reduction applied: %s -> %s after %s reduction cycles": "Automatische Preisreduzierung angewendet: %s -> %s nach %s Reduktionszyklen" + "Auto price reduction kept price %s after attempting %s reduction cycles": "Automatische Preisreduzierung hat Preis %s beibehalten nach dem Versuch von %s Reduktionszyklen" + _repost_cycle_ready: + "Auto price reduction delayed for [%s]: waiting %s more reposts (completed %s, applied %s reductions)": "Automatische Preisreduzierung für [%s] verzögert: Warte %s weitere erneute Veröffentlichungen (abgeschlossen %s, angewendet %s Reduktionen)" + "Auto price reduction already applied for [%s]: %s reductions match %s eligible reposts": "Automatische Preisreduzierung für [%s] bereits angewendet: %s Reduktionen entsprechen %s berechtigten erneuten Veröffentlichungen" + _day_delay_elapsed: + "Auto price reduction delayed for [%s]: waiting %s days (elapsed %s)": "Automatische Preisreduzierung für [%s] verzögert: Warte %s Tage (vergangen %s)" + "Auto price reduction delayed for [%s]: waiting %s days but publish timestamp missing": "Automatische Preisreduzierung für [%s] verzögert: Warte %s Tage, aber Zeitstempel der Veröffentlichung fehlt" publish_ad: "Publishing ad '%s'...": "Veröffentliche Anzeige '%s'..." @@ -531,6 +542,24 @@ kleinanzeigen_bot/update_checker.py: ? "You are on a different commit than the release for channel '%s' (tag: %s). This may mean you are ahead, behind, or on a different branch. Local commit: %s (%s UTC), Release commit: %s (%s UTC)" : "Sie befinden sich auf einem anderen Commit als das Release für Kanal '%s' (Tag: %s). Dies kann bedeuten, dass Sie voraus, hinterher oder auf einem anderen Branch sind. Lokaler Commit: %s (%s UTC), Release-Commit: %s (%s UTC)" +################################################# +kleinanzeigen_bot/model/config_model.py: +################################################# + _validate_config: + "strategy must be specified when auto_price_reduction is enabled": "strategy muss angegeben werden, wenn auto_price_reduction aktiviert ist" + "amount must be specified when auto_price_reduction is enabled": "amount muss angegeben werden, wenn auto_price_reduction aktiviert ist" + "min_price must be specified when auto_price_reduction is enabled": "min_price muss angegeben werden, wenn auto_price_reduction aktiviert ist" + "Percentage reduction amount must not exceed %s": "Prozentuale Reduktionsmenge darf %s nicht überschreiten" + +################################################# +kleinanzeigen_bot/model/ad_model.py: +################################################# + _validate_auto_price_reduction_constraints: + "price must be specified when auto_price_reduction is enabled": "price muss angegeben werden, wenn auto_price_reduction aktiviert ist" + "min_price must not exceed price": "min_price darf price nicht überschreiten" + calculate_auto_price: + "min_price must be specified when auto_price_reduction is enabled": "min_price muss angegeben werden, wenn auto_price_reduction aktiviert ist" + ################################################# kleinanzeigen_bot/model/update_check_state.py: ################################################# diff --git a/src/kleinanzeigen_bot/utils/pydantics.py b/src/kleinanzeigen_bot/utils/pydantics.py index 2624363..a878c9c 100644 --- a/src/kleinanzeigen_bot/utils/pydantics.py +++ b/src/kleinanzeigen_bot/utils/pydantics.py @@ -192,6 +192,14 @@ def __get_message_template(error_code:str) -> str | None: case "decimal_max_digits": return _("Decimal input should have no more than {max_digits} digit{expected_plural} in total") case "decimal_max_places": return _("Decimal input should have no more than {decimal_places} decimal place{expected_plural}") case "decimal_whole_digits": return _("Decimal input should have no more than {whole_digits} digit{expected_plural} before the decimal point") - case "complex_type": return _("Input should be a valid python complex object, a number, or a valid complex string following the rules at https://docs.python.org/3/library/functions.html#complex") - case "complex_str_parsing": return _("Input should be a valid complex string following the rules at https://docs.python.org/3/library/functions.html#complex") + case "complex_type": + return _( + "Input should be a valid python complex object, a number, or a valid complex string " + "following the rules at https://docs.python.org/3/library/functions.html#complex" + ) + case "complex_str_parsing": + return _( + "Input should be a valid complex string following the rules at " + "https://docs.python.org/3/library/functions.html#complex" + ) case _: return None diff --git a/tests/unit/test_ad_model.py b/tests/unit/test_ad_model.py index f75046b..dd586e1 100644 --- a/tests/unit/test_ad_model.py +++ b/tests/unit/test_ad_model.py @@ -1,11 +1,18 @@ # SPDX-FileCopyrightText: © Sebastian Thomschke and contributors # SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ +from __future__ import annotations + import math -from kleinanzeigen_bot.model.ad_model import AdPartial +import pytest + +from kleinanzeigen_bot.model.ad_model import MAX_DESCRIPTION_LENGTH, Ad, AdPartial, ShippingOption, calculate_auto_price +from kleinanzeigen_bot.model.config_model import AdDefaults, AutoPriceReductionConfig +from kleinanzeigen_bot.utils.pydantics import ContextualModel, ContextualValidationError +@pytest.mark.unit def test_update_content_hash() -> None: minimal_ad_cfg = { "id": "123456789", @@ -41,6 +48,37 @@ def test_update_content_hash() -> None: }).update_content_hash().content_hash != minimal_ad_cfg_hash +@pytest.mark.unit +def test_price_reduction_count_does_not_influence_content_hash() -> None: + base_ad_cfg = { + "id": "123456789", + "title": "Test Ad Title", + "category": "160", + "description": "Test Description", + "price_type": "NEGOTIABLE", + } + + hash_without_reposts = AdPartial.model_validate(base_ad_cfg | {"price_reduction_count": 0}).update_content_hash().content_hash + hash_with_reposts = AdPartial.model_validate(base_ad_cfg | {"price_reduction_count": 5}).update_content_hash().content_hash + assert hash_without_reposts == hash_with_reposts + + +@pytest.mark.unit +def test_repost_count_does_not_influence_content_hash() -> None: + base_ad_cfg = { + "id": "123456789", + "title": "Test Ad Title", + "category": "160", + "description": "Test Description", + "price_type": "NEGOTIABLE", + } + + hash_without_reposts = AdPartial.model_validate(base_ad_cfg | {"repost_count": 0}).update_content_hash().content_hash + hash_with_reposts = AdPartial.model_validate(base_ad_cfg | {"repost_count": 5}).update_content_hash().content_hash + assert hash_without_reposts == hash_with_reposts + + +@pytest.mark.unit def test_shipping_costs() -> None: minimal_ad_cfg = { "id": "123456789", @@ -60,3 +98,337 @@ def test_shipping_costs() -> None: assert AdPartial.model_validate(minimal_ad_cfg | {"shipping_costs": " "}).shipping_costs is None assert AdPartial.model_validate(minimal_ad_cfg | {"shipping_costs": None}).shipping_costs is None assert AdPartial.model_validate(minimal_ad_cfg).shipping_costs is None + + +class ShippingOptionWrapper(ContextualModel): + option:ShippingOption + + +@pytest.mark.unit +def test_shipping_option_must_not_be_blank() -> None: + with pytest.raises(ContextualValidationError, match = "must be non-empty and non-blank"): + ShippingOptionWrapper.model_validate({"option": " "}) + + +@pytest.mark.unit +def test_description_length_limit() -> None: + cfg = { + "title": "Description Length", + "category": "160", + "description": "x" * (MAX_DESCRIPTION_LENGTH + 1) + } + + with pytest.raises(ContextualValidationError, match = f"description length exceeds {MAX_DESCRIPTION_LENGTH} characters"): + AdPartial.model_validate(cfg) + + +@pytest.fixture +def base_ad_cfg() -> dict[str, object]: + return { + "title": "Test Ad Title", + "category": "160", + "description": "Test Description", + "price_type": "NEGOTIABLE", + "contact": {"name": "Test User", "zipcode": "12345"}, + "shipping_type": "PICKUP", + "sell_directly": False, + "type": "OFFER", + "active": True + } + + +@pytest.fixture +def complete_ad_cfg(base_ad_cfg:dict[str, object]) -> dict[str, object]: + return base_ad_cfg | { + "republication_interval": 7, + "price": 100, + "auto_price_reduction": { + "enabled": True, + "strategy": "FIXED", + "amount": 5, + "min_price": 50, + "delay_reposts": 0, + "delay_days": 0 + } + } + + +class SparseDumpAdPartial(AdPartial): + def model_dump(self, *args:object, **kwargs:object) -> dict[str, object]: + data = super().model_dump(*args, **kwargs) # type: ignore[arg-type] + data.pop("price_reduction_count", None) + data.pop("repost_count", None) + return data + + +@pytest.mark.unit +def test_auto_reduce_requires_price(base_ad_cfg:dict[str, object]) -> None: + cfg = base_ad_cfg.copy() | { + "auto_price_reduction": { + "enabled": True, + "strategy": "FIXED", + "amount": 5, + "min_price": 50 + } + } + with pytest.raises(ContextualValidationError, match = "price must be specified"): + AdPartial.model_validate(cfg).to_ad(AdDefaults()) + + +@pytest.mark.unit +def test_auto_reduce_requires_strategy(base_ad_cfg:dict[str, object]) -> None: + cfg = base_ad_cfg.copy() | { + "price": 100, + "auto_price_reduction": { + "enabled": True, + "min_price": 50 + } + } + with pytest.raises(ContextualValidationError, match = "strategy must be specified"): + AdPartial.model_validate(cfg).to_ad(AdDefaults()) + + +@pytest.mark.unit +def test_prepare_ad_model_fills_missing_counters(base_ad_cfg:dict[str, object]) -> None: + cfg = base_ad_cfg.copy() | { + "price": 120, + "shipping_type": "SHIPPING", + "sell_directly": False + } + ad = AdPartial.model_validate(cfg).to_ad(AdDefaults()) + + assert ad.auto_price_reduction.delay_reposts == 0 + assert ad.auto_price_reduction.delay_days == 0 + assert ad.price_reduction_count == 0 + assert ad.repost_count == 0 + + +@pytest.mark.unit +def test_min_price_must_not_exceed_price(base_ad_cfg:dict[str, object]) -> None: + cfg = base_ad_cfg.copy() | { + "price": 100, + "auto_price_reduction": { + "enabled": True, + "strategy": "FIXED", + "amount": 5, + "min_price": 120 + } + } + with pytest.raises(ContextualValidationError, match = "min_price must not exceed price"): + AdPartial.model_validate(cfg) + + +@pytest.mark.unit +def test_min_price_validation_defers_to_pydantic_for_invalid_types(base_ad_cfg:dict[str, object]) -> None: + # Test that invalid price/min_price types are handled gracefully + # The safe Decimal comparison should catch conversion errors and defer to Pydantic + cfg = base_ad_cfg.copy() | { + "price": "not_a_number", + "auto_price_reduction": { + "enabled": True, + "strategy": "FIXED", + "amount": 5, + "min_price": 100 + } + } + # Should raise Pydantic validation error for invalid price type, not our custom validation error + with pytest.raises(ContextualValidationError): + AdPartial.model_validate(cfg) + + # Test with invalid min_price type + cfg2 = base_ad_cfg.copy() | { + "price": 100, + "auto_price_reduction": { + "enabled": True, + "strategy": "FIXED", + "amount": 5, + "min_price": "invalid" + } + } + # Should raise Pydantic validation error for invalid min_price type + with pytest.raises(ContextualValidationError): + AdPartial.model_validate(cfg2) + + +@pytest.mark.unit +def test_auto_reduce_requires_min_price(base_ad_cfg:dict[str, object]) -> None: + cfg = base_ad_cfg.copy() | { + "price": 100, + "auto_price_reduction": { + "enabled": True, + "strategy": "FIXED", + "amount": 5 + } + } + with pytest.raises(ContextualValidationError, match = "min_price must be specified"): + AdPartial.model_validate(cfg).to_ad(AdDefaults()) + + +@pytest.mark.unit +def test_to_ad_stabilizes_counters_when_defaults_omit(base_ad_cfg:dict[str, object]) -> None: + cfg = base_ad_cfg.copy() | { + "republication_interval": 7, + "price": 120 + } + ad = AdPartial.model_validate(cfg).to_ad(AdDefaults()) + + assert ad.auto_price_reduction.delay_reposts == 0 + assert ad.auto_price_reduction.delay_days == 0 + assert ad.price_reduction_count == 0 + assert ad.repost_count == 0 + + +@pytest.mark.unit +def test_to_ad_sets_zero_when_counts_missing_from_dump(base_ad_cfg:dict[str, object]) -> None: + cfg = base_ad_cfg.copy() | { + "republication_interval": 7, + "price": 130 + } + ad = SparseDumpAdPartial.model_validate(cfg).to_ad(AdDefaults()) + + assert ad.price_reduction_count == 0 + assert ad.repost_count == 0 + + +@pytest.mark.unit +def test_ad_model_auto_reduce_requires_price(complete_ad_cfg:dict[str, object]) -> None: + cfg = complete_ad_cfg.copy() | {"price": None} + with pytest.raises(ContextualValidationError, match = "price must be specified"): + Ad.model_validate(cfg) + + +@pytest.mark.unit +def test_ad_model_auto_reduce_requires_strategy(complete_ad_cfg:dict[str, object]) -> None: + cfg_copy = complete_ad_cfg.copy() + cfg_copy["auto_price_reduction"] = { + "enabled": True, + "min_price": 50 + } + with pytest.raises(ContextualValidationError, match = "strategy must be specified"): + Ad.model_validate(cfg_copy) + + +@pytest.mark.unit +def test_price_reduction_delay_inherited_from_defaults(complete_ad_cfg:dict[str, object]) -> None: + # When auto_price_reduction is not specified in ad config, it inherits from defaults + cfg = complete_ad_cfg.copy() + cfg.pop("auto_price_reduction", None) # Remove to inherit from defaults + defaults = AdDefaults( + auto_price_reduction = AutoPriceReductionConfig( + enabled = True, + strategy = "FIXED", + amount = 5, + min_price = 50, + delay_reposts = 4, + delay_days = 0 + ) + ) + ad = AdPartial.model_validate(cfg).to_ad(defaults) + assert ad.auto_price_reduction.delay_reposts == 4 + + +@pytest.mark.unit +def test_price_reduction_delay_override_zero(complete_ad_cfg:dict[str, object]) -> None: + cfg = complete_ad_cfg.copy() + # Type-safe way to modify nested dict + cfg["auto_price_reduction"] = { + "enabled": True, + "strategy": "FIXED", + "amount": 5, + "min_price": 50, + "delay_reposts": 0, + "delay_days": 0 + } + defaults = AdDefaults( + auto_price_reduction = AutoPriceReductionConfig( + enabled = True, + strategy = "FIXED", + amount = 5, + min_price = 50, + delay_reposts = 4, + delay_days = 0 + ) + ) + ad = AdPartial.model_validate(cfg).to_ad(defaults) + assert ad.auto_price_reduction.delay_reposts == 0 + + +@pytest.mark.unit +def test_ad_model_auto_reduce_requires_min_price(complete_ad_cfg:dict[str, object]) -> None: + cfg_copy = complete_ad_cfg.copy() + cfg_copy["auto_price_reduction"] = { + "enabled": True, + "strategy": "FIXED", + "amount": 5 + } + with pytest.raises(ContextualValidationError, match = "min_price must be specified"): + Ad.model_validate(cfg_copy) + + +@pytest.mark.unit +def test_ad_model_min_price_must_not_exceed_price(complete_ad_cfg:dict[str, object]) -> None: + cfg_copy = complete_ad_cfg.copy() + cfg_copy["price"] = 100 + cfg_copy["auto_price_reduction"] = { + "enabled": True, + "strategy": "FIXED", + "amount": 5, + "min_price": 150, + "delay_reposts": 0, + "delay_days": 0 + } + with pytest.raises(ContextualValidationError, match = "min_price must not exceed price"): + Ad.model_validate(cfg_copy) + + +@pytest.mark.unit +def test_calculate_auto_price_with_missing_strategy() -> None: + """Test calculate_auto_price when strategy is None but enabled is True (defensive check)""" + # Use model_construct to bypass validation and reach defensive lines 234-235 + config = AutoPriceReductionConfig.model_construct( + enabled = True, strategy = None, amount = None, min_price = 50 + ) + result = calculate_auto_price( + base_price = 100, + auto_price_reduction = config, + target_reduction_cycle = 1 + ) + assert result == 100 # Should return base price when strategy is None + + +@pytest.mark.unit +def test_calculate_auto_price_with_missing_amount() -> None: + """Test calculate_auto_price when amount is None but enabled is True (defensive check)""" + # Use model_construct to bypass validation and reach defensive lines 234-235 + config = AutoPriceReductionConfig.model_construct( + enabled = True, strategy = "FIXED", amount = None, min_price = 50 + ) + result = calculate_auto_price( + base_price = 100, + auto_price_reduction = config, + target_reduction_cycle = 1 + ) + assert result == 100 # Should return base price when amount is None + + +@pytest.mark.unit +def test_calculate_auto_price_raises_when_min_price_none_and_enabled() -> None: + """Test that calculate_auto_price raises ValueError when min_price is None during calculation (defensive check)""" + # Use model_construct to bypass validation and reach defensive line 237-238 + config = AutoPriceReductionConfig.model_construct( + enabled = True, strategy = "FIXED", amount = 10, min_price = None + ) + + with pytest.raises(ValueError, match = "min_price must be specified when auto_price_reduction is enabled"): + calculate_auto_price( + base_price = 100, + auto_price_reduction = config, + target_reduction_cycle = 1 + ) + + +@pytest.mark.unit +def test_auto_price_reduction_config_requires_amount_when_enabled() -> None: + """Test AutoPriceReductionConfig validator requires amount when enabled""" + with pytest.raises(ValueError, match = "amount must be specified when auto_price_reduction is enabled"): + AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = None, min_price = 50) diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index 6657280..2485f01 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -5,7 +5,7 @@ import copy, io, json, logging, os, tempfile # isort: skip from collections.abc import Generator from contextlib import redirect_stdout from datetime import timedelta -from pathlib import Path +from pathlib import Path, PureWindowsPath from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch @@ -1072,6 +1072,124 @@ class TestKleinanzeigenBotShippingOptions: # Verify the file was created in the temporary directory assert ad_file.exists() + @pytest.mark.asyncio + async def test_cross_drive_path_fallback_windows(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None: + """Test that cross-drive path handling falls back to absolute path on Windows.""" + # Create ad config + ad_cfg = Ad.model_validate(base_ad_config | { + "updated_on": "2024-01-01T00:00:00", + "created_on": "2024-01-01T00:00:00", + "auto_price_reduction": { + "enabled": True, + "strategy": "FIXED", + "amount": 10, + "min_price": 50, + "delay_reposts": 0, + "delay_days": 0 + }, + "price": 100, + "repost_count": 1, + "price_reduction_count": 0 + }) + ad_cfg.update_content_hash() + ad_cfg_orig = ad_cfg.model_dump() + + # Simulate Windows cross-drive scenario + # Config on D:, ad file on C: + test_bot.config_file_path = "D:\\project\\config.yaml" + ad_file = "C:\\temp\\test_ad.yaml" + + # Create a sentinel exception to abort publish_ad early + class _SentinelException(Exception): + pass + + # Track what path argument __apply_auto_price_reduction receives + recorded_path:list[str] = [] + + def mock_apply_auto_price_reduction(ad_cfg:Ad, ad_cfg_orig:dict[str, Any], ad_file_relative:str) -> None: + recorded_path.append(ad_file_relative) + raise _SentinelException("Abort early for test") + + # Mock Path to use PureWindowsPath for testing cross-drive behavior + with patch("kleinanzeigen_bot.Path", PureWindowsPath), \ + patch.object(test_bot, "_KleinanzeigenBot__apply_auto_price_reduction", side_effect = mock_apply_auto_price_reduction), \ + patch.object(test_bot, "web_open", new_callable = AsyncMock), \ + patch.object(test_bot, "delete_ad", new_callable = AsyncMock): + # Call publish_ad and expect sentinel exception + try: + await test_bot.publish_ad(ad_file, ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.REPLACE) + pytest.fail("Expected _SentinelException to be raised") + except _SentinelException: + # This is expected - the test aborts early + pass + + # Verify the path argument is the absolute path (fallback behavior) + assert len(recorded_path) == 1 + assert recorded_path[0] == ad_file, f"Expected absolute path fallback, got: {recorded_path[0]}" + + @pytest.mark.asyncio + async def test_auto_price_reduction_only_on_replace_not_update( + self, + test_bot:KleinanzeigenBot, + base_ad_config:dict[str, Any], + tmp_path:Path + ) -> None: + """Test that auto price reduction is ONLY applied on REPLACE mode, not UPDATE.""" + # Create ad with auto price reduction enabled + ad_cfg = Ad.model_validate(base_ad_config | { + "id": 12345, + "price": 200, + "auto_price_reduction": { + "enabled": True, + "strategy": "FIXED", + "amount": 50, + "min_price": 50, + "delay_reposts": 0, + "delay_days": 0 + }, + "repost_count": 1, + "price_reduction_count": 0, + "updated_on": "2024-01-01T00:00:00", + "created_on": "2024-01-01T00:00:00" + }) + ad_cfg.update_content_hash() + ad_cfg_orig = ad_cfg.model_dump() + + # Mock the private __apply_auto_price_reduction method + with patch.object(test_bot, "_KleinanzeigenBot__apply_auto_price_reduction") as mock_apply: + # Mock other dependencies + mock_response = {"statusCode": 200, "statusMessage": "OK", "content": "{}"} + with patch.object(test_bot, "web_find", new_callable = AsyncMock), \ + patch.object(test_bot, "web_input", new_callable = AsyncMock), \ + patch.object(test_bot, "web_click", new_callable = AsyncMock), \ + patch.object(test_bot, "web_open", new_callable = AsyncMock), \ + patch.object(test_bot, "web_select", new_callable = AsyncMock), \ + patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False), \ + patch.object(test_bot, "web_await", new_callable = AsyncMock), \ + patch.object(test_bot, "web_sleep", new_callable = AsyncMock), \ + patch.object(test_bot, "web_execute", new_callable = AsyncMock, return_value = mock_response), \ + patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = mock_response), \ + patch.object(test_bot, "web_scroll_page_down", new_callable = AsyncMock), \ + patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = []), \ + patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock), \ + patch("builtins.input", return_value = ""), \ + patch("kleinanzeigen_bot.utils.misc.ainput", new_callable = AsyncMock, return_value = ""): + + test_bot.page = MagicMock() + test_bot.page.url = "https://www.kleinanzeigen.de/p-anzeige-aufgeben-bestaetigung.html?adId=12345" + test_bot.config.publishing.delete_old_ads = "BEFORE_PUBLISH" + + # Test REPLACE mode - should call __apply_auto_price_reduction + await test_bot.publish_ad(str(tmp_path / "ad.yaml"), ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.REPLACE) + assert mock_apply.call_count == 1, "Auto price reduction should be called on REPLACE" + + # Reset mock + mock_apply.reset_mock() + + # Test MODIFY mode - should NOT call __apply_auto_price_reduction + await test_bot.publish_ad(str(tmp_path / "ad.yaml"), ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY) + assert mock_apply.call_count == 0, "Auto price reduction should NOT be called on MODIFY" + @pytest.mark.asyncio async def test_special_attributes_with_non_string_values(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None: """Test that special attributes with non-string values are converted to strings.""" @@ -1462,3 +1580,45 @@ def test_file_logger_writes_message(tmp_path:Path, caplog:pytest.LogCaptureFixtu with open(log_path, "r", encoding = "utf-8") as f: contents = f.read() assert "Logger test log message" in contents + + +class TestPriceReductionPersistence: + """Tests for price_reduction_count persistence logic.""" + + @pytest.mark.unit + def test_persistence_logic_saves_when_count_positive(self) -> None: + """Test the conditional logic that decides whether to persist price_reduction_count.""" + # Simulate the logic from publish_ad lines 1076-1079 + ad_cfg_orig:dict[str, Any] = {} + + # Test case 1: price_reduction_count = 3 (should persist) + price_reduction_count = 3 + if price_reduction_count is not None and price_reduction_count > 0: + ad_cfg_orig["price_reduction_count"] = price_reduction_count + + assert "price_reduction_count" in ad_cfg_orig + assert ad_cfg_orig["price_reduction_count"] == 3 + + @pytest.mark.unit + def test_persistence_logic_skips_when_count_zero(self) -> None: + """Test that price_reduction_count == 0 does not get persisted.""" + ad_cfg_orig:dict[str, Any] = {} + + # Test case 2: price_reduction_count = 0 (should NOT persist) + price_reduction_count = 0 + if price_reduction_count is not None and price_reduction_count > 0: + ad_cfg_orig["price_reduction_count"] = price_reduction_count + + assert "price_reduction_count" not in ad_cfg_orig + + @pytest.mark.unit + def test_persistence_logic_skips_when_count_none(self) -> None: + """Test that price_reduction_count == None does not get persisted.""" + ad_cfg_orig:dict[str, Any] = {} + + # Test case 3: price_reduction_count = None (should NOT persist) + price_reduction_count = None + if price_reduction_count is not None and price_reduction_count > 0: + ad_cfg_orig["price_reduction_count"] = price_reduction_count + + assert "price_reduction_count" not in ad_cfg_orig diff --git a/tests/unit/test_price_reduction.py b/tests/unit/test_price_reduction.py new file mode 100644 index 0000000..64cf7b7 --- /dev/null +++ b/tests/unit/test_price_reduction.py @@ -0,0 +1,550 @@ +# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ +import logging +from datetime import datetime, timedelta, timezone +from gettext import gettext as _ +from types import SimpleNamespace +from typing import Any, Protocol, runtime_checkable + +import pytest + +import kleinanzeigen_bot +from kleinanzeigen_bot.model.ad_model import calculate_auto_price +from kleinanzeigen_bot.model.config_model import AutoPriceReductionConfig +from kleinanzeigen_bot.utils.pydantics import ContextualValidationError + + +@runtime_checkable +class _ApplyAutoPriceReduction(Protocol): + def __call__(self, ad_cfg:SimpleNamespace, ad_cfg_orig:dict[str, Any], ad_file_relative:str) -> None: + ... + + +@pytest.fixture +def apply_auto_price_reduction() -> _ApplyAutoPriceReduction: + # Return the module-level function directly (no more name-mangling!) + return kleinanzeigen_bot.apply_auto_price_reduction # type: ignore[return-value] + + +@pytest.mark.unit +def test_initial_posting_uses_base_price() -> None: + config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 50) + assert calculate_auto_price( + base_price = 100, + auto_price_reduction = config, + target_reduction_cycle = 0 + ) == 100 + + +@pytest.mark.unit +def test_auto_price_returns_none_without_base_price() -> None: + config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 10) + assert calculate_auto_price( + base_price = None, + auto_price_reduction = config, + target_reduction_cycle = 3 + ) is None + + +@pytest.mark.unit +def test_negative_price_reduction_count_is_treated_like_zero() -> None: + config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 25, min_price = 50) + assert calculate_auto_price( + base_price = 100, + auto_price_reduction = config, + target_reduction_cycle = -3 + ) == 100 + + +@pytest.mark.unit +def test_missing_price_reduction_returns_base_price() -> None: + assert calculate_auto_price( + base_price = 150, + auto_price_reduction = None, + target_reduction_cycle = 4 + ) == 150 + + +@pytest.mark.unit +def test_percentage_reduction_on_float_rounds_half_up() -> None: + config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 12.5, min_price = 50) + assert calculate_auto_price( + base_price = 99.99, + auto_price_reduction = config, + target_reduction_cycle = 1 + ) == 87 + + +@pytest.mark.unit +def test_fixed_reduction_on_float_rounds_half_up() -> None: + config = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 12.4, min_price = 50) + assert calculate_auto_price( + base_price = 80.51, + auto_price_reduction = config, + target_reduction_cycle = 1 + ) == 68 + + +@pytest.mark.unit +def test_percentage_price_reduction_over_time() -> None: + config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 50) + assert calculate_auto_price( + base_price = 100, + auto_price_reduction = config, + target_reduction_cycle = 1 + ) == 90 + assert calculate_auto_price( + base_price = 100, + auto_price_reduction = config, + target_reduction_cycle = 2 + ) == 81 + assert calculate_auto_price( + base_price = 100, + auto_price_reduction = config, + target_reduction_cycle = 3 + ) == 73 + + +@pytest.mark.unit +def test_fixed_price_reduction_over_time() -> None: + config = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 15, min_price = 50) + assert calculate_auto_price( + base_price = 100, + auto_price_reduction = config, + target_reduction_cycle = 1 + ) == 85 + assert calculate_auto_price( + base_price = 100, + auto_price_reduction = config, + target_reduction_cycle = 2 + ) == 70 + assert calculate_auto_price( + base_price = 100, + auto_price_reduction = config, + target_reduction_cycle = 3 + ) == 55 + + +@pytest.mark.unit +def test_min_price_boundary_is_respected() -> None: + config = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 20, min_price = 50) + assert calculate_auto_price( + base_price = 100, + auto_price_reduction = config, + target_reduction_cycle = 5 + ) == 50 + + +@pytest.mark.unit +def test_min_price_zero_is_allowed() -> None: + config = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 5, min_price = 0) + assert calculate_auto_price( + base_price = 20, + auto_price_reduction = config, + target_reduction_cycle = 5 + ) == 0 + + +@pytest.mark.unit +def test_missing_min_price_raises_error() -> None: + # min_price validation happens at config initialization when enabled=True + with pytest.raises(ContextualValidationError, match = "min_price must be specified"): + AutoPriceReductionConfig.model_validate({"enabled": True, "strategy": "PERCENTAGE", "amount": 50, "min_price": None}) + + +@pytest.mark.unit +def test_percentage_above_100_raises_error() -> None: + with pytest.raises(ContextualValidationError, match = "Percentage reduction amount must not exceed 100"): + AutoPriceReductionConfig.model_validate({"enabled": True, "strategy": "PERCENTAGE", "amount": 150, "min_price": 50}) + + +@pytest.mark.unit +def test_feature_disabled_path_leaves_price_unchanged() -> None: + config = AutoPriceReductionConfig(enabled = False, strategy = "PERCENTAGE", amount = 25, min_price = 50) + price = calculate_auto_price( + base_price = 100, + auto_price_reduction = config, + target_reduction_cycle = 4 + ) + assert price == 100 + + +@pytest.mark.unit +def test_apply_auto_price_reduction_logs_drop( + caplog:pytest.LogCaptureFixture, + apply_auto_price_reduction:_ApplyAutoPriceReduction +) -> None: + ad_cfg = SimpleNamespace( + price = 200, + auto_price_reduction = AutoPriceReductionConfig( + enabled = True, strategy = "PERCENTAGE", amount = 25, min_price = 50, delay_reposts = 0, delay_days = 0 + ), + price_reduction_count = 0, + repost_count = 1 + ) + + ad_orig:dict[str, Any] = {} + + with caplog.at_level(logging.INFO): + apply_auto_price_reduction(ad_cfg, ad_orig, "ad_test.yaml") + + expected = _("Auto price reduction applied: %s -> %s after %s reduction cycles") % (200, 150, 1) + assert any(expected in message for message in caplog.messages) + assert ad_cfg.price == 150 + assert ad_cfg.price_reduction_count == 1 + # Note: price_reduction_count is NOT persisted to ad_orig until after successful publish + assert "price_reduction_count" not in ad_orig + + +@pytest.mark.unit +def test_apply_auto_price_reduction_logs_unchanged_price_at_floor( + caplog:pytest.LogCaptureFixture, + apply_auto_price_reduction:_ApplyAutoPriceReduction +) -> None: + # Test scenario: price has been reduced to just above min_price, + # and the next reduction would drop it below, so it gets clamped + ad_cfg = SimpleNamespace( + price = 95, + auto_price_reduction = AutoPriceReductionConfig( + enabled = True, strategy = "FIXED", amount = 10, min_price = 90, delay_reposts = 0, delay_days = 0 + ), + price_reduction_count = 0, + repost_count = 1 + ) + + ad_orig:dict[str, Any] = {} + + with caplog.at_level(logging.INFO): + apply_auto_price_reduction(ad_cfg, ad_orig, "ad_test.yaml") + + # Price: 95 - 10 = 85, clamped to 90 (floor) + # So the effective price is 90, not 95, meaning reduction was applied + expected = _("Auto price reduction applied: %s -> %s after %s reduction cycles") % (95, 90, 1) + assert any(expected in message for message in caplog.messages) + assert ad_cfg.price == 90 + assert ad_cfg.price_reduction_count == 1 + assert "price_reduction_count" not in ad_orig + + +@pytest.mark.unit +def test_apply_auto_price_reduction_warns_when_price_missing( + caplog:pytest.LogCaptureFixture, + apply_auto_price_reduction:_ApplyAutoPriceReduction +) -> None: + ad_cfg = SimpleNamespace( + price = None, + auto_price_reduction = AutoPriceReductionConfig( + enabled = True, strategy = "PERCENTAGE", amount = 25, min_price = 10, delay_reposts = 0, delay_days = 0 + ), + price_reduction_count = 2, + repost_count = 2 + ) + + ad_orig:dict[str, Any] = {} + + with caplog.at_level(logging.WARNING): + apply_auto_price_reduction(ad_cfg, ad_orig, "ad_warning.yaml") + + expected = _("Auto price reduction is enabled for [%s] but no price is configured.") % ("ad_warning.yaml",) + assert any(expected in message for message in caplog.messages) + assert ad_cfg.price is None + + +@pytest.mark.unit +def test_apply_auto_price_reduction_warns_when_min_price_equals_price( + caplog:pytest.LogCaptureFixture, + apply_auto_price_reduction:_ApplyAutoPriceReduction +) -> None: + ad_cfg = SimpleNamespace( + price = 100, + auto_price_reduction = AutoPriceReductionConfig( + enabled = True, strategy = "PERCENTAGE", amount = 25, min_price = 100, delay_reposts = 0, delay_days = 0 + ), + price_reduction_count = 0, + repost_count = 1 + ) + + ad_orig:dict[str, Any] = {} + + with caplog.at_level(logging.WARNING): + apply_auto_price_reduction(ad_cfg, ad_orig, "ad_equal_prices.yaml") + + expected = _("Auto price reduction is enabled for [%s] but min_price equals price (%s) - no reductions will occur.") % ("ad_equal_prices.yaml", 100) + assert any(expected in message for message in caplog.messages) + assert ad_cfg.price == 100 + assert ad_cfg.price_reduction_count == 0 + + +@pytest.mark.unit +def test_apply_auto_price_reduction_respects_repost_delay( + caplog:pytest.LogCaptureFixture, + apply_auto_price_reduction:_ApplyAutoPriceReduction +) -> None: + ad_cfg = SimpleNamespace( + price = 200, + auto_price_reduction = AutoPriceReductionConfig( + enabled = True, strategy = "PERCENTAGE", amount = 25, min_price = 50, delay_reposts = 3, delay_days = 0 + ), + price_reduction_count = 0, + repost_count = 2 + ) + + ad_orig:dict[str, Any] = {} + + with caplog.at_level(logging.INFO): + apply_auto_price_reduction(ad_cfg, ad_orig, "ad_delay.yaml") + + assert ad_cfg.price == 200 + delayed_message = _("Auto price reduction delayed for [%s]: waiting %s more reposts (completed %s, applied %s reductions)") % ("ad_delay.yaml", 2, 2, 0) + assert any(delayed_message in message for message in caplog.messages) + + +@pytest.mark.unit +def test_apply_auto_price_reduction_after_repost_delay_reduces_once( + apply_auto_price_reduction:_ApplyAutoPriceReduction +) -> None: + ad_cfg = SimpleNamespace( + price = 100, + auto_price_reduction = AutoPriceReductionConfig( + enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 50, delay_reposts = 2, delay_days = 0 + ), + price_reduction_count = 0, + repost_count = 3 + ) + + ad_cfg_orig:dict[str, Any] = {} + apply_auto_price_reduction(ad_cfg, ad_cfg_orig, "ad_after_delay.yaml") + + assert ad_cfg.price == 90 + assert ad_cfg.price_reduction_count == 1 + # Note: price_reduction_count is NOT persisted to ad_orig until after successful publish + assert "price_reduction_count" not in ad_cfg_orig + + +@pytest.mark.unit +def test_apply_auto_price_reduction_waits_when_reduction_already_applied( + caplog:pytest.LogCaptureFixture, + apply_auto_price_reduction:_ApplyAutoPriceReduction +) -> None: + ad_cfg = SimpleNamespace( + price = 100, + auto_price_reduction = AutoPriceReductionConfig( + enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 50, delay_reposts = 0, delay_days = 0 + ), + price_reduction_count = 3, + repost_count = 3 + ) + + ad_orig:dict[str, Any] = {} + + with caplog.at_level(logging.DEBUG): + apply_auto_price_reduction(ad_cfg, ad_orig, "ad_already.yaml") + + expected = _("Auto price reduction already applied for [%s]: %s reductions match %s eligible reposts") % ("ad_already.yaml", 3, 3) + assert any(expected in message for message in caplog.messages) + assert ad_cfg.price == 100 + assert ad_cfg.price_reduction_count == 3 + assert "price_reduction_count" not in ad_orig + + +@pytest.mark.unit +def test_apply_auto_price_reduction_respects_day_delay( + monkeypatch:pytest.MonkeyPatch, + caplog:pytest.LogCaptureFixture, + apply_auto_price_reduction:_ApplyAutoPriceReduction +) -> None: + reference = datetime(2025, 1, 1, tzinfo = timezone.utc) + ad_cfg = SimpleNamespace( + price = 150, + auto_price_reduction = AutoPriceReductionConfig( + enabled = True, strategy = "PERCENTAGE", amount = 25, min_price = 50, delay_reposts = 0, delay_days = 3 + ), + price_reduction_count = 0, + repost_count = 1, + updated_on = reference, + created_on = reference + ) + + monkeypatch.setattr("kleinanzeigen_bot.misc.now", lambda: reference + timedelta(days = 1)) + + ad_orig:dict[str, Any] = {} + + with caplog.at_level("INFO"): + apply_auto_price_reduction(ad_cfg, ad_orig, "ad_delay_days.yaml") + + assert ad_cfg.price == 150 + delayed_message = _("Auto price reduction delayed for [%s]: waiting %s days (elapsed %s)") % ("ad_delay_days.yaml", 3, 1) + assert any(delayed_message in message for message in caplog.messages) + + +@pytest.mark.unit +def test_apply_auto_price_reduction_runs_after_delays( + monkeypatch:pytest.MonkeyPatch, + apply_auto_price_reduction:_ApplyAutoPriceReduction +) -> None: + reference = datetime(2025, 1, 1, tzinfo = timezone.utc) + ad_cfg = SimpleNamespace( + price = 120, + auto_price_reduction = AutoPriceReductionConfig( + enabled = True, strategy = "PERCENTAGE", amount = 25, min_price = 60, delay_reposts = 2, delay_days = 3 + ), + price_reduction_count = 0, + repost_count = 3, + updated_on = reference - timedelta(days = 5), + created_on = reference - timedelta(days = 10) + ) + + monkeypatch.setattr("kleinanzeigen_bot.misc.now", lambda: reference) + + ad_orig:dict[str, Any] = {} + apply_auto_price_reduction(ad_cfg, ad_orig, "ad_ready.yaml") + + assert ad_cfg.price == 90 + + +@pytest.mark.unit +def test_apply_auto_price_reduction_delayed_when_timestamp_missing( + caplog:pytest.LogCaptureFixture, + apply_auto_price_reduction:_ApplyAutoPriceReduction +) -> None: + ad_cfg = SimpleNamespace( + price = 200, + auto_price_reduction = AutoPriceReductionConfig( + enabled = True, strategy = "FIXED", amount = 20, min_price = 50, delay_reposts = 0, delay_days = 2 + ), + price_reduction_count = 0, + repost_count = 1, + updated_on = None, + created_on = None + ) + + ad_orig:dict[str, Any] = {} + + with caplog.at_level("INFO"): + apply_auto_price_reduction(ad_cfg, ad_orig, "ad_missing_time.yaml") + + expected = _("Auto price reduction delayed for [%s]: waiting %s days but publish timestamp missing") % ("ad_missing_time.yaml", 2) + assert any(expected in message for message in caplog.messages) + + +@pytest.mark.unit +def test_fractional_reduction_increments_counter_even_when_price_unchanged( + caplog:pytest.LogCaptureFixture, + apply_auto_price_reduction:_ApplyAutoPriceReduction +) -> None: + # Test that small fractional reductions increment the counter even when rounded price doesn't change + # This allows cumulative reductions to eventually show visible effect + ad_cfg = SimpleNamespace( + price = 100, + auto_price_reduction = AutoPriceReductionConfig( + enabled = True, strategy = "FIXED", amount = 0.3, min_price = 50, delay_reposts = 0, delay_days = 0 + ), + price_reduction_count = 0, + repost_count = 1 + ) + + ad_orig:dict[str, Any] = {} + + with caplog.at_level(logging.INFO): + apply_auto_price_reduction(ad_cfg, ad_orig, "ad_fractional.yaml") + + # Price: 100 - 0.3 = 99.7, rounds to 100 (no visible change) + # But counter should still increment for future cumulative reductions + expected = _("Auto price reduction kept price %s after attempting %s reduction cycles") % (100, 1) + assert any(expected in message for message in caplog.messages) + assert ad_cfg.price == 100 + assert ad_cfg.price_reduction_count == 1 # Counter incremented despite no visible price change + assert "price_reduction_count" not in ad_orig + + +@pytest.mark.unit +def test_reduction_value_zero_raises_error() -> None: + with pytest.raises(ContextualValidationError, match = "Input should be greater than 0"): + AutoPriceReductionConfig.model_validate({"enabled": True, "strategy": "PERCENTAGE", "amount": 0, "min_price": 50}) + + +@pytest.mark.unit +def test_reduction_value_negative_raises_error() -> None: + with pytest.raises(ContextualValidationError, match = "Input should be greater than 0"): + AutoPriceReductionConfig.model_validate({"enabled": True, "strategy": "FIXED", "amount": -5, "min_price": 50}) + + +@pytest.mark.unit +def test_percentage_reduction_100_percent() -> None: + config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 100, min_price = 0) + assert calculate_auto_price( + base_price = 150, + auto_price_reduction = config, + target_reduction_cycle = 1 + ) == 0 + + +@pytest.mark.unit +def test_extreme_reduction_cycles() -> None: + # Test that extreme cycle counts don't cause performance issues or errors + config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 0) + result = calculate_auto_price( + base_price = 1000, + auto_price_reduction = config, + target_reduction_cycle = 100 + ) + # With commercial rounding (round after each step), price stabilizes at 5 + # because 5 * 0.9 = 4.5 rounds back to 5 with ROUND_HALF_UP + assert result == 5 + + +@pytest.mark.unit +def test_commercial_rounding_each_step() -> None: + """Test that commercial rounding is applied after each reduction step, not just at the end.""" + config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 0) + # With 135 EUR and 2x 10% reduction: + # Step 1: 135 * 0.9 = 121.5 → rounds to 122 EUR + # Step 2: 122 * 0.9 = 109.8 → rounds to 110 EUR + # (Without intermediate rounding, it would be: 135 * 0.9^2 = 109.35 → 109 EUR) + result = calculate_auto_price( + base_price = 135, + auto_price_reduction = config, + target_reduction_cycle = 2 + ) + assert result == 110 # Commercial rounding result + + +@pytest.mark.unit +def test_extreme_reduction_cycles_with_floor() -> None: + # Test that extreme cycles stop at min_price and don't cause issues + config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 50) + result = calculate_auto_price( + base_price = 1000, + auto_price_reduction = config, + target_reduction_cycle = 1000 + ) + # Should stop at min_price, not go to 0, regardless of cycle count + assert result == 50 + + +@pytest.mark.unit +def test_fractional_min_price_is_rounded_up_with_ceiling() -> None: + # Test that fractional min_price is rounded UP using ROUND_CEILING + # This prevents the price from going below min_price due to int() conversion + # Example: min_price=90.5 should become floor of 91, not 90 + config = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 10, min_price = 90.5) + + # Start at 100, reduce by 10 = 90 + # But min_price=90.5 rounds UP to 91 with ROUND_CEILING + # So the result should be 91, not 90 + result = calculate_auto_price( + base_price = 100, + auto_price_reduction = config, + target_reduction_cycle = 1 + ) + assert result == 91 # Rounded up from 90.5 floor + + # Verify with another fractional value + config2 = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 5, min_price = 49.1) + result2 = calculate_auto_price( + base_price = 60, + auto_price_reduction = config2, + target_reduction_cycle = 3 # 60 - 5 - 5 - 5 = 45, clamped to ceil(49.1) = 50 + ) + assert result2 == 50 # Rounded up from 49.1 floor diff --git a/tests/unit/test_pydantics.py b/tests/unit/test_pydantics.py index 683d19b..407ad59 100644 --- a/tests/unit/test_pydantics.py +++ b/tests/unit/test_pydantics.py @@ -151,6 +151,14 @@ class TestFormatValidationError: [{"loc": ("decimal_max_digits",), "msg": "dummy", "type": "decimal_max_digits", "ctx": {"max_digits": 10, "expected_plural": "s"}}], "Decimal input should have no more than 10 digits in total", ), + ( + [{"loc": ("decimal_max_places",), "msg": "dummy", "type": "decimal_max_places", "ctx": {"decimal_places": 2, "expected_plural": "s"}}], + "Decimal input should have no more than 2 decimal places", + ), + ( + [{"loc": ("decimal_whole_digits",), "msg": "dummy", "type": "decimal_whole_digits", "ctx": {"whole_digits": 3, "expected_plural": ""}}], + "Decimal input should have no more than 3 digits before the decimal point", + ), # Complex number related errors ( [{"loc": ("complex",), "msg": "dummy", "type": "complex_type"}],