mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-12 10:31:50 +01:00
feat: Add automatic price reduction on reposts (#691)
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user