fix: remove individual shipping publishing (#1159)

## ℹ️ Description

- Link to the related issue(s): Issue #1157
- Kleinanzeigen no longer offers individual/custom shipping. This
updates publishing, validation, docs, schemas, translations, and tests
so existing `shipping_costs` configs are handled gracefully instead of
failing against removed DOM controls.

## 📋 Changes Summary

- Keep `shipping_costs` for legacy configs/downloaded ads, but mark it
deprecated in schema/docs and ignore it during publishing.
- Remove publishing interaction with removed individual-shipping DOM
controls.
- Require predefined non-empty `shipping_options` for `sell_directly`.
- Update docs, generated schemas/config artifacts, German translations,
and unit tests.
- Verified live DOM for `sell_directly: true` + `shipping_type:
SHIPPING` + predefined `shipping_options` via local ignored script; no
ad submission performed.

### ⚙️ Type of Change
Select the type(s) of change(s) included in this pull request:
- [x] 🐞 Bug fix (non-breaking change which fixes an issue)
- [ ]  New feature (adds new functionality without breaking existing
usage)
- [ ] 💥 Breaking change (changes that might break existing user setups,
scripts, or configurations)

##  Checklist
Before requesting a review, confirm the following:
- [x] I have reviewed my changes to ensure they meet the project's
standards.
- [x] I have tested my changes and ensured that all tests pass (`pdm run
test`).
- [x] I have formatted the code (`pdm run format`).
- [x] I have verified that linting passes (`pdm run lint`).
- [x] I have updated documentation where necessary.

By submitting this pull request, I confirm that you can use, modify,
copy, and redistribute this contribution, under the terms of your
choice.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Documentation**
* Updated shipping guides to deprecate custom individual shipping costs
and make publishing depend on per-ad predefined `shipping_options`
(DHL/Hermes; selectable options must come from a single size group).
* Added migration guidance replacing `shipping_costs` with the correct
`shipping_options`.
* Clarified “Sell directly” requires `shipping_type: SHIPPING`,
non-empty `shipping_options`, and `FIXED`/`NEGOTIABLE` pricing.

* **Bug Fixes**
* Publishing is blocked when `shipping_costs` is provided without
`shipping_options`.
* Removed unsupported individual-shipping handling and tightened “Sell
directly” eligibility rules.

* **Tests**
* Expanded unit coverage for deprecation behavior, content handling, and
direct-buy validation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jens
2026-06-21 08:11:02 +02:00
committed by GitHub
parent 5ab4015251
commit d384f172b8
11 changed files with 323 additions and 309 deletions

View File

@@ -28,6 +28,8 @@ For full JSON schema with IDE autocompletion support, see:
- [schemas/ad.schema.json](https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/main/schemas/ad.schema.json)
Looking for shipping setup? See [Shipping Configuration](#shipping-configuration), including the [Shipping Options Reference](#shipping-options-reference).
📖 **[Complete Main Configuration Reference →](CONFIGURATION.md)**
Full documentation for `config.yaml` including all options, timeouts, browser settings, update checks, and ad_defaults.
@@ -237,23 +239,62 @@ special_attributes:
### Shipping Configuration
> **Important:** Individual/custom shipping (`shipping_costs`) is no longer available on
> kleinanzeigen.de. Only predefined DHL/Hermes carrier options are supported.
```yaml
shipping_type: # one of: PICKUP, SHIPPING, NOT_APPLICABLE (default: SHIPPING)
shipping_costs: # e.g. 2.95 (for individual postage, keep shipping_type SHIPPING and leave shipping_options empty)
# Specify shipping options / packages.
# It is possible to select multiple packages, but only from one size group (S, M, L)!
# DEPRECATED: Individual/custom shipping is no longer supported.
# If shipping_costs is set without shipping_options, publishing is blocked
# with an error. Remove shipping_costs and use predefined shipping_options
# instead (see below). shipping_costs in downloaded ads is still readable
# for backward compatibility.
shipping_costs: # e.g. 2.95 — BLOCKS publishing if set without shipping_options; migrate!
# Specify predefined shipping options / packages.
# Only DHL and Hermes carrier options are valid. You can select multiple
# packages, but only from one size group (S, M, L)!
# See the "Shipping Options Reference" table below for all available options.
shipping_options: []
shipping_options: [] # non-empty list required for direct-buy (sell_directly)
# Example (size S only):
# shipping_options:
# - DHL_2
# - Hermes_Päckchen
sell_directly: # true or false, requires shipping_type SHIPPING (with shipping_options or shipping_costs) to take effect (default: false)
sell_directly: # true or false, requires shipping_type SHIPPING, non-empty predefined shipping_options, and price_type FIXED or NEGOTIABLE (default: false)
```
**Migration from `shipping_costs` to `shipping_options`:**
If your ad YAML currently uses `shipping_costs` (e.g. `shipping_costs: 4.50`) and
`shipping_type: SHIPPING` with no `shipping_options`, replace it with the appropriate
predefined option from the table below. For example, replace:
```yaml
shipping_type: SHIPPING
shipping_costs: 4.50
shipping_options: []
```
with:
```yaml
shipping_type: SHIPPING
shipping_options:
- DHL_2
```
Choose the option that matches your typical package size. If you use multiple carriers,
select multiple options from the same size group.
**`shipping_costs` in downloaded ads:** Extraction still reads `shipping_costs` from
live ads for backward compatibility. However, attempting to publish an ad that has
`shipping_costs` configured without `shipping_options` will **fail with an error**
you must migrate to predefined `shipping_options` before publishing.
#### Shipping Options Reference
You can select multiple options, but **only from one size group** (S, M, or L). Each option corresponds to a specific carrier package. Prices are set by the carrier and may change over time.
@@ -276,11 +317,11 @@ You can select multiple options, but **only from one size group** (S, M, or L).
**Shipping types:**
- `PICKUP` - Buyer picks up the item
- `SHIPPING` - Item is shipped (requires shipping costs or options)
- `SHIPPING` - Item is shipped. Non-empty `shipping_options` are required for predefined carrier selection and direct-buy (`sell_directly`). Legacy `shipping_costs` is accepted for backward compatibility but ignored during publishing.
- `NOT_APPLICABLE` - Shipping not applicable for this item
**Sell Directly:**
When `sell_directly: true`, buyers can purchase the item directly through the platform without contacting the seller first. This feature requires `shipping_type: SHIPPING` (with either predefined `shipping_options` or individual `shipping_costs`) and a fixed or negotiable price.
When `sell_directly: true`, buyers can purchase the item directly through the platform without contacting the seller first. This feature requires `shipping_type: SHIPPING` **and** a non-empty list of predefined `shipping_options`. Individual `shipping_costs` alone does **not** qualify for direct-buy. A fixed or negotiable price is also required.
### Images
@@ -386,7 +427,7 @@ republication_interval: 7
- **Schema validation errors**: Run `kleinanzeigen-bot verify` (binary) or `pdm run app verify` (source) to see which fields fail validation.
- **Price reduction not applying**: Confirm `auto_price_reduction.enabled` is `true`, `min_price` is set, and you are using `publish` (not `update`, unless `on_update: true`). Run `kleinanzeigen-bot verify` to preview outcomes, or add `-v` for detailed decision data including repost/day-delay state. Remember ad-level values override `ad_defaults`.
- **Shipping configuration issues**: Use `shipping_type: SHIPPING` when setting `shipping_costs` or `shipping_options`, and pick options from a single size group (S/M/L).
- **Shipping configuration issues**: Use `shipping_type: SHIPPING` with non-empty `shipping_options` for predefined DHL/Hermes package shipping and direct-buy. Individual `shipping_costs` is deprecated — publishing an ad with `shipping_costs` and no `shipping_options` will fail with an error. Remove `shipping_costs` from your config and migrate to `shipping_options` (pick options from a single size group S/M/L).
- **Category not found**: Verify the category name or ID and check any custom mappings in `config.yaml`.
- **File naming/prefix mismatch**: Ensure ad files match your `ad_files` glob and prefix (default `ad_`).
- **Image path resolution**: Relative paths are resolved from the ad file location; use absolute paths and check file permissions if images are not found.

View File

@@ -125,8 +125,9 @@ ad_defaults:
price_type: NEGOTIABLE # one of: FIXED, NEGOTIABLE, GIVE_AWAY, NOT_APPLICABLE
shipping_type: SHIPPING # one of: PICKUP, SHIPPING, NOT_APPLICABLE
# NOTE: shipping_costs and shipping_options must be configured per-ad, not as defaults
sell_directly: false # requires shipping_type SHIPPING to take effect
# NOTE: shipping_options must be configured per-ad, not as defaults
# shipping_costs is also per-ad (DEPRECATED — individual shipping removed, publishing fails if set without shipping_options)
sell_directly: false # requires shipping_type SHIPPING, non-empty shipping_options, and price_type FIXED or NEGOTIABLE
contact:
name: ""
street: ""

View File

@@ -76,7 +76,7 @@ ad_defaults:
# • NOT_APPLICABLE
shipping_type: SHIPPING
# enable direct purchase option (only works when shipping_type is SHIPPING)
# enable direct purchase option (requires shipping_type: SHIPPING, non-empty shipping_options, and price_type FIXED or NEGOTIABLE)
sell_directly: false
# default image glob patterns (optional). Leave empty for no default images

View File

@@ -326,6 +326,8 @@
}
],
"default": null,
"deprecated": true,
"description": "DEPRECATED: Individual/custom shipping is no longer supported. Use shipping_options instead.",
"title": "Shipping Costs"
},
"shipping_options": {

View File

@@ -100,7 +100,7 @@
},
"sell_directly": {
"default": false,
"description": "enable direct purchase option (only works when shipping_type is SHIPPING)",
"description": "enable direct purchase option (requires shipping_type: SHIPPING, non-empty shipping_options, and price_type FIXED or NEGOTIABLE)",
"title": "Sell Directly",
"type": "boolean"
},

View File

@@ -187,7 +187,11 @@ class AdPartial(ContextualModel):
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_costs:float | None = Field(
default = None,
json_schema_extra = {"deprecated": True},
description = "DEPRECATED: Individual/custom shipping is no longer supported. Use shipping_options instead.",
)
shipping_options:list[ShippingOption] | None = _OPTIONAL()
sell_directly:bool | None = _OPTIONAL()
images:list[str] | None = _OPTIONAL()
@@ -257,7 +261,12 @@ class AdPartial(ContextualModel):
return values
def update_content_hash(self) -> Self:
"""Calculate and updates the content_hash value for user-modifiable fields of the ad."""
"""Calculate and updates the content_hash value for user-modifiable fields of the ad.
Note: shipping_costs is intentionally included in the content hash during
the deprecation phase to avoid unnecessary re-publishes for users with
legacy configs. A future cleanup phase may remove it.
"""
# 1) Dump to a plain dict, excluding the metadata fields:
raw = self.model_dump(
@@ -442,3 +451,26 @@ class Ad(AdPartial):
# 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
@model_validator(mode = "after")
def _validate_sell_directly(self) -> "Ad":
# Direct-buy rules apply only to non-WANTED ads.
# WANTED ads with sell_directly: true are silently accepted
# (publishing skips direct-buy handling for them).
if self.type == "WANTED" or not self.sell_directly:
return self
if self.shipping_type != "SHIPPING":
raise ValueError(
_("sell_directly requires shipping_type to be SHIPPING")
)
if not self.shipping_options:
raise ValueError(
_("sell_directly requires at least one predefined shipping_options entry (shipping_costs alone is not sufficient)")
)
if self.price_type not in {"FIXED", "NEGOTIABLE"}:
raise ValueError(
_("sell_directly requires price_type to be FIXED or NEGOTIABLE")
)
return self

View File

@@ -98,7 +98,10 @@ class AdDefaults(ContextualModel):
shipping_type:Literal["PICKUP", "SHIPPING", "NOT_APPLICABLE"] = Field(
default = "SHIPPING", description = "shipping method for the item", examples = ["PICKUP", "SHIPPING", "NOT_APPLICABLE"]
)
sell_directly:bool = Field(default = False, description = "enable direct purchase option (only works when shipping_type is SHIPPING)")
sell_directly:bool = Field(
default = False,
description = "enable direct purchase option (requires shipping_type: SHIPPING, non-empty shipping_options, and price_type FIXED or NEGOTIABLE)",
)
images:list[str] | None = Field(
default_factory = list,
description = "default image glob patterns (optional). Leave empty for no default images",

View File

@@ -469,6 +469,12 @@ async def set_pricing_fields(web:WebScrapingMixin, ad_cfg:Ad, ad_defaults:AdDefa
quick_dom = web.timeout("quick_dom")
if ad_cfg.shipping_type == "SHIPPING":
if sell_directly and price_type in {"FIXED", "NEGOTIABLE"}:
# Publishing guard: predefined shipping_options are required for direct-buy.
# Model validator catches most cases; this is a defensive check in publishing.
if not ad_cfg.shipping_options:
raise ValueError(
_("Direct-buy (sell_directly) requires predefined 'shipping_options'. 'shipping_costs' alone is not sufficient.")
)
buy_now_true = await web.web_probe(By.ID, "ad-buy-now-true", timeout = quick_dom)
if buy_now_true is None:
LOG.warning("Direct-buy (sell_directly) is not available for the selected category. Skipping.")
@@ -630,77 +636,26 @@ async def set_shipping(web:WebScrapingMixin, ad_cfg:Ad, mode:AdUpdateStrategy =
await web.web_click(By.XPATH, '//button[contains(., "Andere Versandmethoden")]')
await set_shipping_options(web, ad_cfg, mode)
elif ad_cfg.shipping_costs is not None:
# Fail-fast: configured shipping_costs with no shipping_options will not
# publish safely (Kleinanzeigen defaults may not match the article).
raise ValueError(
_("Individual shipping (shipping_costs) is no longer supported. "
"Remove shipping_costs and configure predefined 'shipping_options' instead.")
)
else:
# Ensure shipping is enabled before opening the dialog (may already be selected)
# No shipping_options and no shipping_costs: enable shipping in the
# form but do NOT open the shipping-options dialog — the ad uses
# Kleinanzeigen platform defaults for shipping.
try:
await web.web_click(By.ID, "ad-shipping-enabled-yes", timeout = short_timeout)
await web.web_sleep(500, 800)
except TimeoutError as ex:
LOG.debug("Shipping enabled toggle not found before options dialog: %s", ex)
LOG.debug("Shipping enabled toggle not found: %s", ex)
# no options. only costs. Set custom shipping cost
try:
await web.web_click(By.ID, "ad-shipping-options")
except TimeoutError as ex:
LOG.debug(ex, exc_info = True)
LOG.warning("Shipping options dialog entry not found. Legacy '.versand_s' select UI is no longer supported and requires dedicated rebuild.")
raise TimeoutError(_("Unable to open shipping options dialog!")) from ex
try:
# when "Andere Versandmethoden" is not available, then we are already on the individual page
await web.web_click(By.XPATH, '//button[contains(., "Andere Versandmethoden")]')
except TimeoutError:
# Dialog option not present; already on the individual shipping page.
pass
# only click on "Individueller Versand" when the price input is not available, otherwise it's already checked
# (important for mode = UPDATE)
individual_price_elem = await web.web_probe(By.ID, "ad-individual-shipping-price", timeout = short_timeout)
if individual_price_elem is None:
# Input not visible yet; click the individual shipping option.
try:
await web.web_click(By.ID, "ad-individual-shipping-checkbox-control")
except TimeoutError as ex:
LOG.debug(ex, exc_info = True)
raise TimeoutError(_("Unable to select individual shipping option!")) from ex
if ad_cfg.shipping_costs is not None:
price_str = str(ad_cfg.shipping_costs).replace(".", ",")
# Native DOM setter + React-aware events: send_keys gets wiped by
# React re-render after the ad-individual-shipping-checkbox-control click.
# A re-render between web_find and web_execute inside web_set_input_value can
# also leave the write as a silent no-op, so verify and retry before "Fertig".
max_attempts = 3
for attempt in range(1, max_attempts + 1):
try:
await web.web_set_input_value("ad-individual-shipping-price", price_str)
actual = await web.web_execute("document.getElementById('ad-individual-shipping-price')?.value")
except TimeoutError as ex:
# A re-render landing on web_find inside web_set_input_value or on the
# readback web_execute can raise here; treat either as a transient
# failure so the outer loop can retry instead of bailing.
LOG.debug(ex, exc_info = True)
if attempt >= max_attempts:
raise TimeoutError(_("Unable to set shipping price!")) from ex
await web.web_sleep(300, 500)
continue
if actual == price_str:
break
if attempt >= max_attempts:
raise TimeoutError(_("Unable to set shipping price!"))
LOG.debug("shipping price not persisted (attempt %d/%d): got %r, expected %r", attempt, max_attempts, actual, price_str)
await web.web_sleep(300, 500)
else:
LOG.debug(
"Shipping option 'ad-individual-shipping-checkbox-control' selected but no shipping_costs provided; "
"leaving field 'ad-individual-shipping-price' unchanged."
)
try:
await web.web_click(By.XPATH, '//button[contains(., "Fertig")]')
except TimeoutError as ex:
LOG.debug(ex, exc_info = True)
raise TimeoutError(_("Unable to close shipping dialog!")) from ex
LOG.debug(
"No shipping options and no shipping_costs configured; "
"shipping is enabled at the form level but no carrier/package is selected."
)
async def set_shipping_options(web:WebScrapingMixin, ad_cfg:Ad, mode:AdUpdateStrategy = AdUpdateStrategy.REPLACE) -> None:

View File

@@ -258,6 +258,7 @@ kleinanzeigen_bot/publishing_form.py:
set_pricing_fields:
"Failed to set price type '%s'": "Fehler beim Setzen des Preistyps '%s'"
"Direct-buy (sell_directly) is not available for the selected category. Skipping.": "Direktkauf (sell_directly) ist für die ausgewählte Kategorie nicht verfügbar. Überspringe."
"Direct-buy (sell_directly) requires predefined 'shipping_options'. 'shipping_costs' alone is not sufficient.": "Direktkauf (sell_directly) erfordert vordefinierte 'shipping_options'. 'shipping_costs' allein ist nicht ausreichend."
upload_images:
" -> found %s": "-> %s gefunden"
@@ -284,12 +285,8 @@ kleinanzeigen_bot/publishing_form.py:
"Shipping combobox button has no id attribute": "Versand-Combobox-Button hat kein ID-Attribut"
"Shipping fieldset is rendered, but the pickup radio is missing; page may not be fully loaded.": "Das Versand-Fieldset wird gerendert, aber die Abholungs-Option fehlt; die Seite wurde möglicherweise nicht vollständig geladen."
"Unsupported shipping_type: %s": "Nicht unterstützter shipping_type: %s"
? "Shipping options dialog entry not found. Legacy '.versand_s' select UI is no longer supported and requires dedicated rebuild."
: "Einstieg in den Versandoptionen-Dialog nicht gefunden. Die alte '.versand_s'-Select-UI wird nicht mehr unterstützt und muss gezielt neu umgesetzt werden."
"Unable to open shipping options dialog!": "Versandoptionen-Dialog konnte nicht geöffnet werden!"
"Unable to select individual shipping option!": "Individuelle Versandoption konnte nicht ausgewählt werden!"
"Unable to set shipping price!": "Versandpreis konnte nicht gesetzt werden!"
"Unable to close shipping dialog!": "Versanddialog konnte nicht geschlossen werden!"
? "Individual shipping (shipping_costs) is no longer supported. Remove shipping_costs and configure predefined 'shipping_options' instead."
: "Individueller Versand (shipping_costs) wird nicht mehr unterstützt. Entferne shipping_costs und konfiguriere stattdessen vordefinierte 'shipping_options'."
set_shipping_options:
"Failed to configure shipping options in dialog!": "Versandoptionen konnten im Dialog nicht konfiguriert werden!"
@@ -945,6 +942,10 @@ kleinanzeigen_bot/model/ad_model.py:
"title length exceeds {max} characters": "Die Titellänge überschreitet {max} Zeichen"
_calculate_auto_price_internal:
"min_price must be specified when auto_price_reduction is enabled": "min_price muss angegeben werden, wenn auto_price_reduction aktiviert ist"
_validate_sell_directly:
"sell_directly requires shipping_type to be SHIPPING": "sell_directly erfordert shipping_type: SHIPPING"
"sell_directly requires at least one predefined shipping_options entry (shipping_costs alone is not sufficient)": "sell_directly erfordert mindestens einen vordefinierten shipping_options Eintrag (shipping_costs allein reicht nicht aus)"
"sell_directly requires price_type to be FIXED or NEGOTIABLE": "sell_directly erfordert price_type: FIXED oder NEGOTIABLE"
#################################################
kleinanzeigen_bot/model/update_check_state.py:

View File

@@ -19,6 +19,19 @@ from kleinanzeigen_bot.model.config_model import AdDefaults, AutoPriceReductionC
from kleinanzeigen_bot.utils.pydantics import ContextualModel, ContextualValidationError
@pytest.mark.unit
def test_shipping_costs_deprecated_in_schema() -> None:
"""shipping_costs field is still present and parseable, but marked deprecated in JSON schema."""
# Field still works for parsing (deprecation is schema-level only)
minimal_cfg = {"title": "Test Title", "category": "160", "description": "Test"}
assert AdPartial.model_validate(minimal_cfg | {"shipping_costs": 4.95}).shipping_costs == 4.95
# Verify deprecation marker is present in the generated JSON schema
schema = AdPartial.model_json_schema()
shipping_costs_props = schema["properties"]["shipping_costs"]
assert shipping_costs_props.get("deprecated") is True
@pytest.mark.unit
def test_update_content_hash() -> None:
minimal_ad_cfg = {
@@ -325,3 +338,99 @@ 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)
# ── sell_directly validator tests ──────────────────────────────────────────────
@pytest.mark.unit
def test_sell_directly_accepted_with_valid_options(base_ad_cfg:dict[str, object]) -> None:
"""OFFER ad with sell_directly: true + SHIPPING + predefined options + FIXED passes."""
cfg = base_ad_cfg.copy() | {
"price_type": "FIXED",
"shipping_type": "SHIPPING",
"shipping_options": ["DHL_2"],
"sell_directly": True,
"price": 50,
}
ad = AdPartial.model_validate(cfg).to_ad(AdDefaults())
assert ad.sell_directly is True
assert ad.shipping_options == ["DHL_2"]
@pytest.mark.unit
def test_sell_directly_rejected_without_options(base_ad_cfg:dict[str, object]) -> None:
"""OFFER ad with sell_directly: true but no shipping_options fails."""
cfg = base_ad_cfg.copy() | {
"price_type": "FIXED",
"shipping_type": "SHIPPING",
"sell_directly": True,
"price": 50,
}
with pytest.raises(ContextualValidationError, match = "shipping_option"):
AdPartial.model_validate(cfg).to_ad(AdDefaults())
@pytest.mark.unit
def test_sell_directly_rejected_with_shipping_costs_only(base_ad_cfg:dict[str, object]) -> None:
"""OFFER ad with sell_directly: true + shipping_costs but no shipping_options fails."""
cfg = base_ad_cfg.copy() | {
"price_type": "FIXED",
"shipping_type": "SHIPPING",
"shipping_costs": 4.95,
"sell_directly": True,
"price": 50,
}
with pytest.raises(ContextualValidationError, match = "shipping_option"):
AdPartial.model_validate(cfg).to_ad(AdDefaults())
@pytest.mark.unit
def test_sell_directly_wanted_not_rejected(base_ad_cfg:dict[str, object]) -> None:
"""WANTED ad with sell_directly: true must NOT be rejected by the validator."""
cfg = base_ad_cfg.copy() | {
"type": "WANTED",
"sell_directly": True,
}
# Should pass without error even with minimal shipping config
ad = AdPartial.model_validate(cfg).to_ad(AdDefaults())
assert ad.type == "WANTED"
assert ad.sell_directly is True
@pytest.mark.unit
def test_sell_directly_rejected_with_pickup(base_ad_cfg:dict[str, object]) -> None:
"""OFFER ad with sell_directly: true + PICKUP shipping_type fails."""
cfg = base_ad_cfg.copy() | {
"shipping_type": "PICKUP",
"sell_directly": True,
}
with pytest.raises(ContextualValidationError, match = "shipping_type"):
AdPartial.model_validate(cfg).to_ad(AdDefaults())
@pytest.mark.unit
def test_sell_directly_rejected_with_give_away(base_ad_cfg:dict[str, object]) -> None:
"""OFFER ad with sell_directly + GIVE_AWAY price_type fails."""
cfg = base_ad_cfg.copy() | {
"shipping_type": "SHIPPING",
"shipping_options": ["DHL_2"],
"price_type": "GIVE_AWAY",
"sell_directly": True,
}
with pytest.raises(ContextualValidationError, match = "price_type"):
AdPartial.model_validate(cfg).to_ad(AdDefaults())
@pytest.mark.unit
def test_shipping_costs_in_content_hash_during_deprecation() -> None:
"""Verify shipping_costs still affects the content hash during deprecation phase."""
base = {
"title": "Test Ad Title",
"category": "160",
"description": "Test Description",
}
hash_without_costs = AdPartial.model_validate(base).update_content_hash().content_hash
hash_with_costs = AdPartial.model_validate(base | {"shipping_costs": 4.95}).update_content_hash().content_hash
assert hash_with_costs != hash_without_costs, \
"shipping_costs must still affect the content hash during deprecation"

View File

@@ -1148,9 +1148,17 @@ class TestPricingFields:
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
caplog:pytest.LogCaptureFixture,
) -> None:
"""Shipping ads with sell_directly enabled should continue if buy-now-true control is unavailable."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "sell_directly": True, "price_type": "FIXED", "price": 100})
caplog.set_level(logging.WARNING, logger = LOG.name)
ad_cfg = Ad.model_validate(
base_ad_config | {
"shipping_type": "SHIPPING",
"sell_directly": True,
"price_type": "FIXED",
"price": 100,
"shipping_options": ["DHL_2"]})
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
@@ -1161,8 +1169,49 @@ class TestPricingFields:
):
await set_pricing_fields(test_bot, ad_cfg, test_bot.config.ad_defaults)
# Assert warning about category-unavailable buy-now
warning_messages = [r.message for r in caplog.records if r.levelno == logging.WARNING]
assert any("sell_directly" in m and "not available" in m for m in warning_messages), \
"Should log warning when buy-now-true is unavailable"
# Assert continuation (description field was set after the warning)
mock_set.assert_any_await("ad-description", "desc")
@pytest.mark.asyncio
async def test_sell_directly_without_shipping_options_raises(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""Direct-buy without predefined shipping_options should raise ValueError (publishing guard).
Uses model_construct to bypass Phase 1 model validator and test the publishing
guard independently.
"""
ad_cfg = Ad.model_construct(
**base_ad_config | {
"shipping_type": "SHIPPING",
"sell_directly": True,
"price_type": "FIXED",
"price": 100,
"shipping_options": [],
}
)
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock) as mock_probe,
patch.object(test_bot, "web_click", new_callable = AsyncMock),
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock),
patch("kleinanzeigen_bot.publishing_form.get_ad_description", return_value = "desc"),
pytest.raises(ValueError, match = "Direct-buy.*shipping_options"),
):
await set_pricing_fields(test_bot, ad_cfg, test_bot.config.ad_defaults)
# Price type dropdown may be clicked before the guard fires; the guard
# must prevent buy-now-true from being probed/clicked.
buy_now_probes = [c for c in mock_probe.await_args_list if len(c.args) >= 2 and "ad-buy-now" in str(c.args[1])]
assert not buy_now_probes, "guard must prevent buy-now DOM probing"
@pytest.mark.asyncio
async def test_buy_now_true_interaction_timeout_propagates(
self,
@@ -1170,7 +1219,13 @@ class TestPricingFields:
base_ad_config:dict[str, Any],
) -> None:
"""Existing direct-buy controls should still fail if interaction times out."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "sell_directly": True, "price_type": "FIXED", "price": 100})
ad_cfg = Ad.model_validate(
base_ad_config | {
"shipping_type": "SHIPPING",
"sell_directly": True,
"price_type": "FIXED",
"price": 100,
"shipping_options": ["DHL_2"]})
buy_now_elem = MagicMock()
@@ -1374,258 +1429,73 @@ class TestShippingDialogFlow:
mock_check.assert_not_awaited()
mock_click.assert_not_awaited()
@pytest.mark.asyncio
async def test_shipping_without_options_uses_radio_and_dialog(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
"""Shipping without package options should use radio + dialog flow."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "shipping_options": [], "shipping_costs": 4.95})
with (
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = MagicMock()),
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock) as mock_set_input,
patch.object(test_bot, "web_execute", new_callable = AsyncMock, return_value = "4,95"),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
):
await set_shipping(test_bot, ad_cfg)
click_args = [c.args for c in mock_click.await_args_list]
assert any(len(a) >= 2 and a[0] == By.ID and a[1] == "ad-individual-shipping-checkbox-control" for a in click_args)
assert any("Fertig" in str(a[1]) for a in click_args if len(a) >= 2)
mock_set_input.assert_awaited_once_with("ad-individual-shipping-price", "4,95")
# ------------------------------------------------------------------
# No-options branch: fail-fast for configured costs, platform-default for none
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_shipping_finish_timeout_raises(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
"""Timeout while confirming shipping dialog should raise a clear error."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "shipping_options": []})
async def click_side_effect(selector_type:By, selector_value:str, **_:Any) -> None:
if selector_type == By.XPATH and "Fertig" in selector_value:
raise TimeoutError("finish timeout")
with (
patch.object(test_bot, "web_click", new_callable = AsyncMock, side_effect = click_side_effect),
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = MagicMock()),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
pytest.raises(TimeoutError, match = "Unable to close shipping dialog!"),
):
await set_shipping(test_bot, ad_cfg)
@pytest.mark.asyncio
async def test_shipping_without_options_does_not_toggle_checkbox_when_price_input_visible(
async def test_shipping_no_options_with_costs_raises(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""When price input is already visible, individual-shipping checkbox is not toggled."""
"""No shipping_options with shipping_costs: raise ValueError (fail-fast)."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "shipping_options": [], "shipping_costs": 4.95})
with (
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = [None, MagicMock()]),
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = MagicMock()),
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock) as mock_set_input,
patch.object(test_bot, "web_execute", new_callable = AsyncMock, return_value = "4,95"),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
patch.object(test_bot, "web_click", new_callable = AsyncMock),
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
pytest.raises(ValueError, match = "shipping_costs.*no longer supported"),
):
await set_shipping(test_bot, ad_cfg)
click_args = [c.args for c in mock_click.await_args_list]
assert not any(len(a) >= 2 and a[0] == By.ID and a[1] == "ad-individual-shipping-checkbox-control" for a in click_args)
mock_set_input.assert_awaited_once_with("ad-individual-shipping-price", "4,95")
@pytest.mark.asyncio
async def test_shipping_price_lost_to_react_rerender_raises(
async def test_shipping_no_options_no_costs_platform_default(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
caplog:pytest.LogCaptureFixture,
) -> None:
"""When React re-render swallows the shipping price, the dialog must NOT be closed."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "shipping_options": [], "shipping_costs": 4.95})
async def set_input_side_effect(element_id:str, value:str) -> None:
pass
"""No shipping_options and no shipping_costs: click enabled, debug log only, no warning."""
caplog.set_level(logging.WARNING, logger = LOG.name)
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "shipping_options": [], "shipping_costs": None})
with (
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = MagicMock()),
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock, side_effect = set_input_side_effect),
patch.object(test_bot, "web_execute", new_callable = AsyncMock, return_value = None),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
pytest.raises(TimeoutError, match = "Unable to set shipping price!"),
):
await set_shipping(test_bot, ad_cfg)
# Fertig must never be clicked when the price was not confirmed
fertig_clicks = [c for c in mock_click.await_args_list if c.args and len(c.args) >= 2 and "Fertig" in str(c.args[1])]
assert not fertig_clicks, "Fertig was clicked despite shipping price not being set"
# Must click shipping-enabled-yes
enabled_yes_clicks = [
c for c in mock_click.await_args_list
if len(c.args) >= 2 and c.args[0] == By.ID and c.args[1] == "ad-shipping-enabled-yes"
]
assert enabled_yes_clicks, "Should click shipping-enabled-yes"
@pytest.mark.asyncio
async def test_shipping_price_recovers_when_readback_matches_on_retry(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""First attempt mismatches (readback empty), second attempt succeeds — must close the dialog normally."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "shipping_options": [], "shipping_costs": 4.95})
# Must NOT open shipping-options dialog
options_clicks = [
c for c in mock_click.await_args_list
if len(c.args) >= 2 and c.args[0] == By.ID and c.args[1] == "ad-shipping-options"
]
assert not options_clicks, "Must NOT open shipping-options dialog"
with (
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = MagicMock()),
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock) as mock_set_input,
patch.object(test_bot, "web_execute", new_callable = AsyncMock, side_effect = [None, "4,95"]),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
):
await set_shipping(test_bot, ad_cfg)
# Must NOT touch any individual-shipping selectors
individual_clicks = [
c for c in mock_click.await_args_list
if len(c.args) >= 2 and "ad-individual-shipping" in str(c.args[1])
]
assert not individual_clicks, "Must NOT touch individual-shipping selectors"
assert mock_set_input.await_count == 2, "expected exactly one retry after the first mismatch"
inter_attempt_sleeps = [c for c in mock_sleep.await_args_list if c.args == (300, 500)]
assert len(inter_attempt_sleeps) == 1, "expected one inter-attempt backoff sleep"
fertig_clicks = [c for c in mock_click.await_args_list if c.args and len(c.args) >= 2 and "Fertig" in str(c.args[1])]
assert fertig_clicks, "Fertig must be clicked once the readback confirms the price"
@pytest.mark.asyncio
async def test_shipping_price_retries_when_readback_raises_transiently(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""A TimeoutError from the readback web_execute must be retried, not propagated on the first occurrence."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "shipping_options": [], "shipping_costs": 4.95})
readback_results:list[str | Exception] = [TimeoutError("readback raced with re-render"), "4,95"]
async def readback_side_effect(*_args:Any, **_kwargs:Any) -> str | None:
result = readback_results.pop(0)
if isinstance(result, Exception):
raise result
return result
with (
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = MagicMock()),
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock) as mock_set_input,
patch.object(test_bot, "web_execute", new_callable = AsyncMock, side_effect = readback_side_effect),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
):
await set_shipping(test_bot, ad_cfg)
assert mock_set_input.await_count == 2, "expected one retry after the readback raised"
fertig_clicks = [c for c in mock_click.await_args_list if c.args and len(c.args) >= 2 and "Fertig" in str(c.args[1])]
assert fertig_clicks, "Fertig must be clicked once a later readback confirms the price"
# No warning should be logged (only debug)
warning_messages = [r.message for r in caplog.records if r.levelno == logging.WARNING]
assert not any("shipping_costs" in m for m in warning_messages)
assert not any("Individual shipping" in m for m in warning_messages)
# ------------------------------------------------------------------
# set_shipping commercial/pro combobox: error paths
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_shipping_versand_combobox_no_id(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""Commercial combobox with no id attribute should raise TimeoutError."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING"})
shipping_combobox = MagicMock()
shipping_combobox.attrs = {} # No "id" key
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = shipping_combobox),
patch.object(test_bot, "web_select_button_combobox", new_callable = AsyncMock),
pytest.raises(TimeoutError, match = "Failed to set shipping attribute for type 'SHIPPING'!"),
):
await set_shipping(test_bot, ad_cfg)
@pytest.mark.asyncio
async def test_shipping_versand_combobox_unsupported_type(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""Commercial combobox with unsupported shipping_type should raise ValueError.
Bypasses Pydantic validation by patching the attribute at runtime because
the model only permits SHIPPING/PICKUP/NOT_APPLICABLE.
"""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING"})
shipping_combobox = MagicMock()
shipping_combobox.attrs = {"id": "uhren.versand"}
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = shipping_combobox),
patch.object(test_bot, "web_select_button_combobox", new_callable = AsyncMock),
patch.object(ad_cfg, "shipping_type", "INVALID_TYPE"),
pytest.raises(ValueError, match = "Unsupported shipping_type: INVALID_TYPE"),
):
await set_shipping(test_bot, ad_cfg)
@pytest.mark.asyncio
async def test_shipping_versand_combobox_select_timeout(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""Timeout from web_select_button_combobox should be re-raised with descriptive message."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING"})
shipping_combobox = MagicMock()
shipping_combobox.attrs = {"id": "uhren.versand"}
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = shipping_combobox),
patch.object(
test_bot, "web_select_button_combobox", new_callable = AsyncMock, side_effect = TimeoutError("combobox selection failed")
),
pytest.raises(TimeoutError, match = "Failed to set shipping attribute for type 'SHIPPING'!"),
):
await set_shipping(test_bot, ad_cfg)
# ------------------------------------------------------------------
# individual shipping-cost retry: web_set_input_value transient TimeoutError
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_shipping_price_recovers_after_set_input_timeout(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""A TimeoutError from web_set_input_value must be retried and recover on a subsequent attempt.
Flow: attempt1 (set_input TimeoutError → retry), attempt2 (set_input ok,
readback None → retry), attempt3 (set_input ok, readback matches → done).
"""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "shipping_options": [], "shipping_costs": 4.95})
set_input_attempts = 0
async def set_input_side_effect(element_id:str, value:str) -> None:
nonlocal set_input_attempts
set_input_attempts += 1
if set_input_attempts == 1:
raise TimeoutError("set_input raced with re-render")
with (
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = MagicMock()),
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock, side_effect = set_input_side_effect) as mock_set_input,
patch.object(test_bot, "web_execute", new_callable = AsyncMock, side_effect = [None, "4,95"]),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
):
await set_shipping(test_bot, ad_cfg)
# attempt1 raised (caught), attempt2 set_input ok but readback=None (retry),
# attempt3 set_input ok and readback="4,95" (done) → 3 calls total
assert mock_set_input.await_count == 3, "expected 2 retries: one after set_input TimeoutError, one after mismatched readback"
inter_attempt_sleeps = [c for c in mock_sleep.await_args_list if c.args == (300, 500)]
assert len(inter_attempt_sleeps) == 2, "expected two inter-attempt backoff sleeps"
fertig_clicks = [c for c in mock_click.await_args_list if c.args and len(c.args) >= 2 and "Fertig" in str(c.args[1])]
assert fertig_clicks, "Fertig must be clicked once the retry succeeds"
class TestShippingOptionsDialog:
"""Tests for set_shipping_options using carrier-code-based selectors."""