mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-07-09 12:41:05 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user