Files
kleinanzeigen-bot/tests/unit/test_publishing_workflow.py
Jens cf6662dd26 fix: require strict fetch for title cleanup (#1206)
## ℹ️ Description
- Link to the related issue(s): Issue #
- Fixes a remaining fail-closed gap in BEFORE_PUBLISH title cleanup for
ID-less ads when the published-ad list cannot be fetched completely.

## 📋 Changes Summary
- Require an additional strict published-ad fetch when BEFORE_PUBLISH
title cleanup may match by title for ID-less ads.
- Fail closed for ID-less publish candidates if strict published-ad
fetch is incomplete, before submit/delete actions.
- Keep ID-based candidates on the normal published-ad list so they can
continue safely.
- Add regression tests for strict-fetch usage and fail-closed behavior.
- Add German translations for the new log messages.

### ⚙️ 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.

Validation performed locally:
- `pdm run pytest tests/unit/test_publishing_workflow.py
tests/unit/test_delete_flow.py`
- `pdm run format`
- `pdm run lint`
- `pdm run test`


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

* **New Features**
* Enhanced publishing workflow to use more precise “published ad” data
when title-based cleanup is enabled, improving match-and-replace
behavior.
* **Bug Fixes**
* Publishing now fails safely when strict published-ad data is
incomplete, avoiding retries and deletion actions when precision can’t
be guaranteed.
* For ads lacking a known identifier, the workflow intelligently selects
the best available published-ad list or skips with a warning when strict
data is unavailable.
* **Tests**
* Added unit test coverage for strict-vs-non-strict published-ad
fetching scenarios and fail-closed behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-05 17:04:42 +02:00

1268 lines
58 KiB
Python

# SPDX-FileCopyrightText: © Jens Bergmann and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
"""Tests for publishing workflow orchestration."""
import asyncio
import copy
import fnmatch
import json
import logging
import os
from collections.abc import Iterator
from contextlib import ExitStack, contextmanager
from pathlib import Path, PureWindowsPath
from typing import Any
from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
from nodriver.core.connection import ProtocolException
from kleinanzeigen_bot.app import KleinanzeigenBot
from kleinanzeigen_bot.model.ad_model import Ad, AdUpdateStrategy
from kleinanzeigen_bot.model.config_model import (
AutoPriceReductionConfig,
DiagnosticsConfig,
)
from kleinanzeigen_bot.published_ads import PublishedAdsFetchIncompleteError
from kleinanzeigen_bot.publishing_workflow import SUBMISSION_MAX_RETRIES, PostPublishPersistenceError
from kleinanzeigen_bot.utils.exceptions import CategoryResolutionError, PublishSubmissionUncertainError
from tests.conftest import build_published_ads, build_update_ad
@pytest.fixture
def mock_page() -> MagicMock:
"""Provide a mock page object for testing."""
mock = MagicMock()
mock.sleep = AsyncMock()
mock.evaluate = AsyncMock()
mock.click = AsyncMock()
mock.type = AsyncMock()
return mock
class TestKleinanzeigenBotUpdateAdsResilience:
@pytest.mark.asyncio
@pytest.mark.parametrize(
("first_failure", "first_title"),
[
(TimeoutError("transient timeout"), "Timeout Ad"),
(ProtocolException(MagicMock(), "connection lost", 0), "Protocol Failing"),
],
ids = ["timeout_error", "protocol_exception"],
)
async def test_update_ads_continues_after_retryable_first_ad_failure(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
first_failure:Exception,
first_title:str,
) -> None:
ad_one = build_update_ad(base_ad_config, 101, first_title)
ad_two = build_update_ad(base_ad_config, 102, "Success Ad")
async def publish_side_effect(
_web:Any,
_ad_file:str,
ad_cfg:Ad,
_ad_cfg_orig:dict[str, Any],
_published_ads:list[dict[str, Any]],
_mode:AdUpdateStrategy,
**kwargs:Any,
) -> None:
if ad_cfg.id == 101:
raise first_failure
with (
patch(
"kleinanzeigen_bot.published_ads.fetch_published_ads",
new_callable = AsyncMock,
return_value = build_published_ads((101, "active"), (102, "active")),
),
patch("kleinanzeigen_bot.publishing_workflow.publish_ad", new_callable = AsyncMock, side_effect = publish_side_effect) as publish_mock,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
):
await test_bot.update_ads([ad_one, ad_two])
assert publish_mock.await_count == SUBMISSION_MAX_RETRIES + 1
call_ids = [call.args[2].id for call in publish_mock.await_args_list]
assert call_ids.count(101) == SUBMISSION_MAX_RETRIES
assert call_ids.count(102) == 1
assert all(call.args[5] == AdUpdateStrategy.MODIFY for call in publish_mock.await_args_list)
assert sleep_mock.await_count == SUBMISSION_MAX_RETRIES - 1
@pytest.mark.asyncio
async def test_update_ads_publish_submission_uncertain_is_not_retried(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
ad_one = build_update_ad(base_ad_config, 301, "Uncertain Update")
ad_two = build_update_ad(base_ad_config, 302, "Second Update")
async def publish_side_effect(
_web:Any,
_ad_file:str,
ad_cfg:Ad,
_ad_cfg_orig:dict[str, Any],
_published_ads:list[dict[str, Any]],
_mode:AdUpdateStrategy,
**kwargs:Any,
) -> None:
if ad_cfg.id == 301:
raise PublishSubmissionUncertainError("submission may have succeeded before failure")
with (
patch(
"kleinanzeigen_bot.published_ads.fetch_published_ads",
new_callable = AsyncMock,
return_value = build_published_ads((301, "active"), (302, "active")),
),
patch("kleinanzeigen_bot.publishing_workflow.publish_ad", new_callable = AsyncMock, side_effect = publish_side_effect) as publish_mock,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
):
await test_bot.update_ads([ad_one, ad_two])
assert publish_mock.await_count == 2
sleep_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_update_ads_category_resolution_error_is_not_retried(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
ad_one = build_update_ad(base_ad_config, 303, "Category Error Update")
ad_two = build_update_ad(base_ad_config, 304, "Second Update")
async def publish_side_effect(
_web:Any,
_ad_file:str,
ad_cfg:Ad,
_ad_cfg_orig:dict[str, Any],
_published_ads:list[dict[str, Any]],
_mode:AdUpdateStrategy,
**kwargs:Any,
) -> None:
if ad_cfg.id == 303:
raise CategoryResolutionError("no suggestion matched")
with (
patch(
"kleinanzeigen_bot.published_ads.fetch_published_ads",
new_callable = AsyncMock,
return_value = build_published_ads((303, "active"), (304, "active")),
),
patch("kleinanzeigen_bot.publishing_workflow.publish_ad", new_callable = AsyncMock, side_effect = publish_side_effect) as publish_mock,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
):
await test_bot.update_ads([ad_one, ad_two])
assert [call.args[2].id for call in publish_mock.await_args_list] == [303, 304]
sleep_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_update_ads_cancelled_error_propagates_immediately(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
ad_one = build_update_ad(base_ad_config, 401, "Cancelled Ad")
ad_two = build_update_ad(base_ad_config, 402, "Should Not Run")
with (
patch(
"kleinanzeigen_bot.published_ads.fetch_published_ads",
new_callable = AsyncMock,
return_value = build_published_ads((401, "active"), (402, "active")),
),
patch("kleinanzeigen_bot.publishing_workflow.publish_ad", new_callable = AsyncMock, side_effect = asyncio.CancelledError()) as publish_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
pytest.raises(asyncio.CancelledError),
):
await test_bot.update_ads([ad_one, ad_two])
assert publish_mock.await_count == 1
@pytest.mark.asyncio
async def test_update_ads_publishing_result_timeout_is_non_fatal(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
ad_one = build_update_ad(base_ad_config, 501, "Result Timeout")
with (
patch(
"kleinanzeigen_bot.published_ads.fetch_published_ads",
new_callable = AsyncMock,
return_value = build_published_ads((501, "active")),
),
patch("kleinanzeigen_bot.publishing_workflow.publish_ad", new_callable = AsyncMock) as publish_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = TimeoutError("result timeout")),
):
await test_bot.update_ads([ad_one])
publish_mock.assert_awaited_once()
class TestKleinanzeigenBotPublishAdsBasics:
"""Publish-ads orchestration tests moved from TestKleinanzeigenBotBasics."""
@pytest.mark.asyncio
async def test_publish_ads_triggers_publish_and_cleanup(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
) -> None:
"""Simulate publish job wiring without hitting the live site."""
test_bot.page = mock_page
test_bot.config.publishing.delete_old_ads = "AFTER_PUBLISH"
test_bot.keep_old_ads = False
payload:dict[str, Any] = {"ads": [], "paging": {"pageNum": 1, "last": 1}}
ad_cfgs:list[tuple[str, Ad, dict[str, Any]]] = [("ad.yaml", Ad.model_validate(base_ad_config), {})]
with (
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = {"content": json.dumps(payload)}) as web_request_mock,
patch("kleinanzeigen_bot.publishing_workflow.publish_ad", new_callable = AsyncMock) as publish_ad_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True) as web_await_mock,
patch("kleinanzeigen_bot.delete_flow.delete_ad", new_callable = AsyncMock) as delete_ad_mock,
):
await test_bot.publish_ads(ad_cfgs)
# web_request is called once for initial published-ads snapshot
expected_url = f"{test_bot.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT&pageNum=1"
web_request_mock.assert_awaited_once_with(expected_url)
publish_ad_mock.assert_awaited_once()
call_args = publish_ad_mock.call_args
assert call_args is not None
assert call_args.args[1] == "ad.yaml"
assert call_args.args[2] is ad_cfgs[0][1]
assert call_args.args[5] == AdUpdateStrategy.REPLACE
web_await_mock.assert_awaited_once()
delete_ad_mock.assert_awaited_once_with(
web = test_bot, root_url = test_bot.root_url,
ad_cfg = ad_cfgs[0][1],
published_ads_list = [],
delete_old_ads_by_title = False,
)
@pytest.mark.asyncio
async def test_publish_ads_uses_millisecond_retry_delay_on_retryable_failure(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
) -> None:
"""Retry branch should sleep with explicit millisecond delay and reset price-reduction mutations."""
test_bot.page = mock_page
test_bot.keep_old_ads = True
ad_cfg = Ad.model_validate(base_ad_config | {"price": 100, "price_reduction_count": 0, "repost_count": 1})
ad_cfg_orig = copy.deepcopy(base_ad_config)
ad_file = "ad.yaml"
ads_response = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})}
seen_prices:list[tuple[int | None, int | None]] = []
async def publish_side_effect(
_web:Any,
_ad_file:str,
candidate_cfg:Ad,
_candidate_orig:dict[str, Any],
_published_ads:list[dict[str, Any]],
_mode:AdUpdateStrategy,
**kwargs:Any,
) -> None:
seen_prices.append((candidate_cfg.price, candidate_cfg.price_reduction_count))
if len(seen_prices) == 1:
# Simulate in-memory mutation done by apply_auto_price_reduction before a failed attempt.
candidate_cfg.price = 90
candidate_cfg.price_reduction_count = 1
raise TimeoutError("transient")
with (
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = ads_response),
patch("kleinanzeigen_bot.publishing_workflow.publish_ad", new_callable = AsyncMock, side_effect = publish_side_effect) as publish_mock,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
):
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
assert publish_mock.await_count == 2
assert seen_prices == [(100, 0), (100, 0)]
sleep_mock.assert_awaited_once_with(2_000)
@pytest.mark.asyncio
async def test_publish_ads_does_not_retry_when_submission_state_is_uncertain(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
) -> None:
"""Post-submit uncertainty must fail closed and skip retries."""
test_bot.page = mock_page
test_bot.keep_old_ads = True
ad_cfg = Ad.model_validate(base_ad_config)
ad_cfg_orig = copy.deepcopy(base_ad_config)
ad_file = "ad.yaml"
with (
patch.object(
test_bot,
"web_request",
new_callable = AsyncMock,
return_value = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})},
),
patch(
"kleinanzeigen_bot.publishing_workflow.publish_ad",
new_callable = AsyncMock,
side_effect = PublishSubmissionUncertainError("submission may have succeeded before failure"),
) as publish_mock,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock,
):
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
assert publish_mock.await_count == 1
sleep_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_publish_ads_does_not_retry_on_category_resolution_error(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
) -> None:
"""CategoryResolutionError is deterministic configuration failure -> no retry, fail fast."""
test_bot.page = mock_page
test_bot.keep_old_ads = True
ad_cfg = Ad.model_validate(base_ad_config)
ad_cfg_orig = copy.deepcopy(base_ad_config)
with (
patch.object(
test_bot,
"web_request",
new_callable = AsyncMock,
return_value = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})},
),
patch(
"kleinanzeigen_bot.publishing_workflow.publish_ad",
new_callable = AsyncMock,
side_effect = CategoryResolutionError("no suggestion matched"),
) as publish_mock,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock,
):
await test_bot.publish_ads([("ad.yaml", ad_cfg, ad_cfg_orig)])
assert publish_mock.await_count == 1
sleep_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_publish_ads_treats_post_publish_persistence_error_as_fail_closed(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
caplog:pytest.LogCaptureFixture,
) -> None:
test_bot.page = mock_page
test_bot.config.publishing.delete_old_ads = "AFTER_PUBLISH"
test_bot.keep_old_ads = False
ad_cfg = Ad.model_validate(base_ad_config)
ad_cfg_orig = copy.deepcopy(base_ad_config)
ad_file = "ad.yaml"
with (
patch.object(
test_bot,
"web_request",
new_callable = AsyncMock,
return_value = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})},
),
patch(
"kleinanzeigen_bot.publishing_workflow.publish_ad",
new_callable = AsyncMock,
side_effect = PostPublishPersistenceError(
ad_id = 12345,
ad_title = ad_cfg.title,
original = RuntimeError("disk full"),
),
) as publish_ad_mock,
patch.object(
test_bot,
"_capture_publish_error_diagnostics_if_enabled",
new_callable = AsyncMock,
) as capture_mock,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock) as web_await_mock,
patch("kleinanzeigen_bot.delete_flow.delete_ad", new_callable = AsyncMock) as delete_ad_mock,
caplog.at_level("INFO"),
):
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
assert web_await_mock.await_count == 0
assert sleep_mock.await_count == 0
assert publish_ad_mock.await_count == 1
assert delete_ad_mock.await_count == 0
capture_mock.assert_awaited_once()
assert any("DONE: (Re-)published 0 ads (1 failed after retries)" in record.getMessage() for record in caplog.records)
assert any("Persistence failed for 'Test Title' after ad submission" in record.getMessage() for record in caplog.records)
assert any("Ad ID: 12345" in record.getMessage() for record in caplog.records)
@pytest.mark.asyncio
async def test_publish_ads_fetches_published_ads_strictly_for_id_less_title_cleanup(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
caplog:pytest.LogCaptureFixture,
) -> None:
test_bot.config.publishing.delete_old_ads = "BEFORE_PUBLISH"
test_bot.config.publishing.delete_old_ads_by_title = True
ad_cfg = Ad.model_validate(base_ad_config)
ad_cfg_orig = copy.deepcopy(base_ad_config)
ad_file = "ad.yaml"
non_strict_ads = [{"id": 10, "state": "active"}]
strict_ads = [
{"id": 10, "state": "active"},
{"id": 11, "state": "active"},
]
with (
patch(
"kleinanzeigen_bot.published_ads.fetch_published_ads",
new_callable = AsyncMock,
side_effect = [non_strict_ads, strict_ads],
) as fetch_mock,
patch("kleinanzeigen_bot.publishing_workflow.publish_ad", new_callable = AsyncMock) as publish_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
patch("kleinanzeigen_bot.delete_flow.delete_ad", new_callable = AsyncMock),
):
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
fetch_mock.assert_has_awaits([
call(test_bot, test_bot.root_url),
call(test_bot, test_bot.root_url, strict = True),
])
assert publish_mock.await_count == 1
assert publish_mock.call_args.args[4] == strict_ads
summary = [record for record in caplog.records if "DONE:" in record.getMessage()]
assert any("DONE: (Re-)published 1" in record.getMessage() for record in summary)
@pytest.mark.asyncio
async def test_publish_ads_fails_closed_when_strict_published_ads_fetch_fails_for_id_less_candidates(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
caplog:pytest.LogCaptureFixture,
) -> None:
test_bot.config.publishing.delete_old_ads = "BEFORE_PUBLISH"
test_bot.config.publishing.delete_old_ads_by_title = True
ad_cfg = Ad.model_validate(base_ad_config)
ad_cfg_orig = copy.deepcopy(base_ad_config)
ad_file = "ad.yaml"
with (
patch(
"kleinanzeigen_bot.published_ads.fetch_published_ads",
new_callable = AsyncMock,
side_effect = [[], PublishedAdsFetchIncompleteError("incomplete published-ad fetch")],
) as fetch_mock,
patch("kleinanzeigen_bot.publishing_workflow.publish_ad", new_callable = AsyncMock) as publish_mock,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock) as await_mock,
patch("kleinanzeigen_bot.delete_flow.delete_ad", new_callable = AsyncMock) as delete_mock,
caplog.at_level(logging.INFO),
):
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
assert fetch_mock.await_count == 2
publish_mock.assert_not_awaited()
sleep_mock.assert_not_awaited()
await_mock.assert_not_awaited()
delete_mock.assert_not_awaited()
summary = [record for record in caplog.records if "DONE:" in record.getMessage()]
assert any("DONE: (Re-)published 0 ads (1 failed after retries)" in record.getMessage() for record in summary)
@pytest.mark.asyncio
async def test_publish_ads_keep_old_does_not_require_strict_title_cleanup_fetch(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
test_bot.config.publishing.delete_old_ads = "BEFORE_PUBLISH"
test_bot.config.publishing.delete_old_ads_by_title = True
test_bot.keep_old_ads = True
ad_cfg = Ad.model_validate(base_ad_config)
ad_cfg_orig = copy.deepcopy(base_ad_config)
ad_file = "ad.yaml"
published_ads = [{"id": 10, "state": "active"}]
with (
patch(
"kleinanzeigen_bot.published_ads.fetch_published_ads",
new_callable = AsyncMock,
return_value = published_ads,
) as fetch_mock,
patch("kleinanzeigen_bot.publishing_workflow.publish_ad", new_callable = AsyncMock) as publish_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
patch("kleinanzeigen_bot.delete_flow.delete_ad", new_callable = AsyncMock) as delete_mock,
):
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
fetch_mock.assert_awaited_once_with(test_bot, test_bot.root_url)
publish_mock.assert_awaited_once()
assert publish_mock.call_args.args[4] == published_ads
delete_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_update_ads_persistence_failure_is_not_retried_and_still_processes_next_ad(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
ad_one = build_update_ad(base_ad_config, 401, "Persistence Error Update")
ad_two = build_update_ad(base_ad_config, 402, "Healthy Update")
async def publish_side_effect(
_web:Any,
_ad_file:str,
ad_cfg:Ad,
_ad_cfg_orig:dict[str, Any],
_published_ads:list[dict[str, Any]],
_mode:AdUpdateStrategy,
**kwargs:Any,
) -> None:
if ad_cfg.id == 401:
raise PostPublishPersistenceError(
ad_id = 401,
ad_title = ad_cfg.title,
original = RuntimeError("disk full"),
)
with (
patch(
"kleinanzeigen_bot.published_ads.fetch_published_ads",
new_callable = AsyncMock,
return_value = build_published_ads((401, "active"), (402, "active")),
),
patch(
"kleinanzeigen_bot.publishing_workflow.publish_ad",
new_callable = AsyncMock,
side_effect = publish_side_effect,
) as publish_mock,
patch.object(test_bot, "_capture_publish_error_diagnostics_if_enabled", new_callable = AsyncMock) as capture_mock,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True) as web_await_mock,
):
await test_bot.update_ads([ad_one, ad_two])
assert publish_mock.await_count == 2
sleep_mock.assert_not_awaited()
web_await_mock.assert_awaited_once()
capture_mock.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("mode", "expected_path"),
[
(AdUpdateStrategy.REPLACE, "/p-anzeige-aufgeben-schritt2.html"),
(AdUpdateStrategy.MODIFY, "/p-anzeige-bearbeiten.html?adId=12345"),
],
ids = ["replace", "modify"],
)
async def test_publish_ad_keeps_pre_submit_timeouts_retryable(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mode:AdUpdateStrategy,
expected_path:str,
) -> None:
"""Timeouts before submit boundary should remain plain retryable failures and force reload."""
ad_cfg = Ad.model_validate(base_ad_config | {"id": 12345, "shipping_type": "NOT_APPLICABLE", "price_type": "NOT_APPLICABLE"})
ad_cfg_orig = copy.deepcopy(base_ad_config)
expected_url = f"{test_bot.root_url}{expected_path}"
test_bot.keep_old_ads = True
with (
patch.object(test_bot, "web_open", new_callable = AsyncMock) as web_open_mock,
patch.object(test_bot, "dismiss_consent_banner", new_callable = AsyncMock),
patch("kleinanzeigen_bot.publishing_form.set_category", new_callable = AsyncMock, side_effect = TimeoutError("image upload timeout")),
pytest.raises(TimeoutError, match = "image upload timeout"),
):
await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], mode)
web_open_mock.assert_awaited_once_with(expected_url, reload_if_already_open = True)
class TestDisplayCounterProgression:
"""Regression tests for issue #977: progress counter must increment for every ad, including skipped ones."""
@pytest.mark.asyncio
async def test_publish_ads_counter_progression_with_paused_ads(
self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any], caplog:pytest.LogCaptureFixture
) -> None:
"""Display counter must advance for paused ads, and only non-paused ads are published."""
ad_cfgs = [
build_update_ad(base_ad_config, 101, "Paused Ad 1"),
build_update_ad(base_ad_config, 102, "Active Ad 102"),
build_update_ad(base_ad_config, 103, "Paused Ad 2"),
]
published_ads = build_published_ads((101, "paused"), (102, "active"), (103, "paused"))
with (
caplog.at_level(logging.INFO),
patch("kleinanzeigen_bot.published_ads.fetch_published_ads", new_callable = AsyncMock, return_value = published_ads),
patch("kleinanzeigen_bot.publishing_workflow.publish_ad", new_callable = AsyncMock) as publish_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
):
await test_bot.publish_ads(ad_cfgs)
processing = [r for r in caplog.records if r.message.startswith("Processing")]
assert len(processing) == 3
assert "1/3" in processing[0].message
assert "2/3" in processing[1].message
assert "3/3" in processing[2].message
skip_msgs = [r for r in caplog.records if "Skipping because ad is reserved" in r.message]
assert len(skip_msgs) == 2
publish_mock.assert_awaited_once()
assert publish_mock.call_args.args[2].id == 102
summary = [r for r in caplog.records if "DONE:" in r.message]
assert any("1 ad" in r.message for r in summary)
@pytest.mark.asyncio
async def test_update_ads_counter_progression_with_paused_ads(
self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any], caplog:pytest.LogCaptureFixture
) -> None:
"""Display counter must advance for paused ads, and only non-paused ads are updated."""
ad_cfgs = [
build_update_ad(base_ad_config, 201, "Paused Ad 1"),
build_update_ad(base_ad_config, 202, "Active Ad 202"),
build_update_ad(base_ad_config, 203, "Paused Ad 2"),
]
published_ads = build_published_ads((201, "paused"), (202, "active"), (203, "paused"))
with (
caplog.at_level(logging.INFO),
patch("kleinanzeigen_bot.published_ads.fetch_published_ads", new_callable = AsyncMock, return_value = published_ads),
patch("kleinanzeigen_bot.publishing_workflow.publish_ad", new_callable = AsyncMock) as publish_mock,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
):
await test_bot.update_ads(ad_cfgs)
processing = [r for r in caplog.records if r.message.startswith("Processing")]
assert len(processing) == 3
assert "1/3" in processing[0].message
assert "2/3" in processing[1].message
assert "3/3" in processing[2].message
skip_msgs = [r for r in caplog.records if "Skipping because ad is reserved" in r.message]
assert len(skip_msgs) == 2
publish_mock.assert_awaited_once()
assert publish_mock.call_args.args[2].id == 202
summary = [r for r in caplog.records if "DONE:" in r.message]
assert any("1 ad" in r.message for r in summary)
@pytest.mark.asyncio
async def test_update_ads_counter_includes_not_found_ads(
self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any], caplog:pytest.LogCaptureFixture
) -> None:
"""Display counter must advance even for ads not found in published ads."""
ad_cfgs = [
build_update_ad(base_ad_config, 301, "Not Found Ad"),
build_update_ad(base_ad_config, 302, "Active Ad 302"),
]
published_ads = build_published_ads((302, "active"))
with (
caplog.at_level(logging.INFO),
patch("kleinanzeigen_bot.published_ads.fetch_published_ads", new_callable = AsyncMock, return_value = published_ads),
patch("kleinanzeigen_bot.publishing_workflow.publish_ad", new_callable = AsyncMock) as publish_mock,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
):
await test_bot.update_ads(ad_cfgs)
processing = [r for r in caplog.records if r.message.startswith("Processing")]
assert len(processing) == 2
assert "1/2" in processing[0].message
assert "2/2" in processing[1].message
assert "Not Found Ad" in processing[0].message
skip_msgs = [r for r in caplog.records if "SKIPPED" in r.message and "not found" in r.message]
assert len(skip_msgs) == 1
publish_mock.assert_awaited_once()
assert publish_mock.call_args.args[2].id == 302
class TestKleinanzeigenBotDiagnostics:
@pytest.fixture
def diagnostics_ad_config(self) -> dict[str, Any]:
return {
"active": True,
"type": "OFFER",
"title": "Test ad title",
"description": "Test description",
"category": "161/176/sonstige",
"price_type": "NEGOTIABLE",
"shipping_type": "PICKUP",
"sell_directly": False,
"contact": {
"name": "Tester",
"zipcode": "12345",
},
"republication_interval": 7,
}
@pytest.mark.unit
@pytest.mark.asyncio
async def test_publish_ads_captures_diagnostics_on_failures(
self,
test_bot:KleinanzeigenBot,
tmp_path:Path,
diagnostics_ad_config:dict[str, Any],
) -> None:
"""Ensure publish failures capture diagnostics artifacts."""
log_file_path = tmp_path / "test.log"
log_file_path.write_text("Test log content\n", encoding = "utf-8")
test_bot.log_file_path = str(log_file_path)
test_bot.config.diagnostics = DiagnosticsConfig.model_validate({"capture_on": {"publish": True}, "output_dir": str(tmp_path)})
page = MagicMock()
page.save_screenshot = AsyncMock()
page.get_content = AsyncMock(return_value = "<html></html>")
page.sleep = AsyncMock()
page.url = "https://example.com/fail"
test_bot.page = page
ad_cfg = Ad.model_validate(diagnostics_ad_config)
ad_cfg_orig = copy.deepcopy(diagnostics_ad_config)
ad_file = str(tmp_path / "ad_000001_Test.yml")
ads_response = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})}
with (
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = ads_response),
patch("kleinanzeigen_bot.publishing_workflow.publish_ad", new_callable = AsyncMock, side_effect = TimeoutError("boom")),
):
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
expected_retries = SUBMISSION_MAX_RETRIES
assert page.save_screenshot.await_count == expected_retries
assert page.get_content.await_count == expected_retries
entries = os.listdir(tmp_path)
html_files = [name for name in entries if fnmatch.fnmatch(name, "publish_error_*_attempt*_ad_000001_Test.html")]
json_files = [name for name in entries if fnmatch.fnmatch(name, "publish_error_*_attempt*_ad_000001_Test.json")]
assert len(html_files) == expected_retries
assert len(json_files) == expected_retries
@pytest.mark.unit
@pytest.mark.asyncio
async def test_publish_ads_captures_log_copy_when_enabled(
self,
test_bot:KleinanzeigenBot,
tmp_path:Path,
diagnostics_ad_config:dict[str, Any],
) -> None:
"""Ensure publish failures copy log file when capture_log_copy is enabled."""
log_file_path = tmp_path / "test.log"
log_file_path.write_text("Test log content\n", encoding = "utf-8")
test_bot.log_file_path = str(log_file_path)
test_bot.config.diagnostics = DiagnosticsConfig.model_validate({"capture_on": {"publish": True}, "capture_log_copy": True, "output_dir": str(tmp_path)})
page = MagicMock()
page.save_screenshot = AsyncMock()
page.get_content = AsyncMock(return_value = "<html></html>")
page.sleep = AsyncMock()
page.url = "https://example.com/fail"
test_bot.page = page
ad_cfg = Ad.model_validate(diagnostics_ad_config)
ad_cfg_orig = copy.deepcopy(diagnostics_ad_config)
ad_file = str(tmp_path / "ad_000001_Test.yml")
ads_response = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})}
with (
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = ads_response),
patch("kleinanzeigen_bot.publishing_workflow.publish_ad", new_callable = AsyncMock, side_effect = TimeoutError("boom")),
):
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
entries = os.listdir(tmp_path)
log_files = [name for name in entries if fnmatch.fnmatch(name, "publish_error_*_attempt*_ad_000001_Test.log")]
assert len(log_files) == SUBMISSION_MAX_RETRIES
@pytest.mark.unit
@pytest.mark.asyncio
async def test_publish_ads_does_not_capture_diagnostics_when_disabled(
self,
test_bot:KleinanzeigenBot,
tmp_path:Path,
diagnostics_ad_config:dict[str, Any],
) -> None:
"""Ensure diagnostics are not captured when disabled."""
test_bot.config.diagnostics = DiagnosticsConfig.model_validate({"capture_on": {"publish": False}, "output_dir": str(tmp_path)})
page = MagicMock()
page.save_screenshot = AsyncMock()
page.get_content = AsyncMock(return_value = "<html></html>")
page.sleep = AsyncMock()
page.url = "https://example.com/fail"
test_bot.page = page
ad_cfg = Ad.model_validate(diagnostics_ad_config)
ad_cfg_orig = copy.deepcopy(diagnostics_ad_config)
ad_file = str(tmp_path / "ad_000001_Test.yml")
with (
patch.object(
test_bot,
"web_request",
new_callable = AsyncMock,
return_value = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})},
),
patch("kleinanzeigen_bot.publishing_workflow.publish_ad", new_callable = AsyncMock, side_effect = TimeoutError("boom")),
):
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
page.save_screenshot.assert_not_called()
page.get_content.assert_not_called()
entries = os.listdir(tmp_path)
html_files = [name for name in entries if fnmatch.fnmatch(name, "publish_error_*_attempt*_ad_000001_Test.html")]
json_files = [name for name in entries if fnmatch.fnmatch(name, "publish_error_*_attempt*_ad_000001_Test.json")]
assert not html_files
assert not json_files
class TestPublishAdPostSubmitUncertainty:
"""Post-submit uncertainty tests for publish_ad, moved from TestKleinanzeigenBotBasics."""
@staticmethod
def _build_publish_ad_cfg(base_ad_config:dict[str, Any]) -> tuple[Ad, dict[str, Any]]:
"""Build ad config and original dict for publish_ad tests."""
ad_cfg = Ad.model_validate(base_ad_config | {"id": 12345, "shipping_type": "NOT_APPLICABLE", "price_type": "NOT_APPLICABLE"})
ad_cfg_orig = copy.deepcopy(base_ad_config)
return ad_cfg, ad_cfg_orig
@contextmanager
def _mock_post_submit_dependencies(
self,
test_bot:KleinanzeigenBot,
mock_page:MagicMock,
*,
web_await_side_effect:BaseException | None = None,
web_execute_side_effect:list[Any] | None = None,
redirect_recovery_return:int | None = None,
redirect_recovery_side_effect:BaseException | None = None,
mock_redirect_recovery:bool = True,
include_success_mocks:bool = False,
) -> Iterator[None]:
"""Mock all post-submit publish_ad dependencies for confirmation fallback tests.
Parameters
----------
web_await_side_effect: Exception to raise from web_await (simulates confirmation timeout).
redirect_recovery_return: Return value for _try_recover_ad_id_from_redirect.
redirect_recovery_side_effect: Exception to raise from _try_recover_ad_id_from_redirect.
include_success_mocks: If True, also mock dicts.save_dict (for success-path tests).
"""
test_bot.page = mock_page
common_patches:list[Any] = [
patch.object(test_bot, "web_open", new_callable = AsyncMock),
patch.object(test_bot, "dismiss_consent_banner", new_callable = AsyncMock),
patch("kleinanzeigen_bot.publishing_form.set_category", new_callable = AsyncMock),
patch("kleinanzeigen_bot.publishing_form.set_pricing_fields", new_callable = AsyncMock),
patch("kleinanzeigen_bot.publishing_form.set_special_attributes", new_callable = AsyncMock),
patch("kleinanzeigen_bot.publishing_form.set_contact_fields", new_callable = AsyncMock),
patch("kleinanzeigen_bot.captcha_flow.check_and_wait_for_captcha", new_callable = AsyncMock),
patch.object(test_bot, "web_input", new_callable = AsyncMock),
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
patch.object(test_bot, "web_click", new_callable = AsyncMock),
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False),
patch.object(test_bot, "web_execute", new_callable = AsyncMock, side_effect = web_execute_side_effect),
patch.object(test_bot, "web_find", new_callable = AsyncMock),
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = []),
patch.object(test_bot, "_web_find_all_once", new_callable = AsyncMock, return_value = []),
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = web_await_side_effect),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
]
if mock_redirect_recovery:
common_patches.append(
patch(
"kleinanzeigen_bot.publishing_submission._try_recover_ad_id_from_redirect",
new_callable = AsyncMock,
return_value = redirect_recovery_return,
side_effect = redirect_recovery_side_effect,
),
)
if include_success_mocks:
common_patches.append(patch("kleinanzeigen_bot.utils.dicts.save_dict"))
with ExitStack() as stack:
for p in common_patches:
stack.enter_context(p)
yield
@pytest.mark.asyncio
@pytest.mark.parametrize(
"web_await_error",
[TimeoutError("confirmation timeout"), ProtocolException(MagicMock(), "connection lost", 0)],
ids = ["timeout", "protocol-exception"],
)
async def test_publish_ad_marks_post_submit_errors_as_uncertain(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
web_await_error:Exception,
) -> None:
"""Post-submit exceptions (TimeoutError, ProtocolException) should be converted to non-retryable uncertainty."""
ad_cfg, ad_cfg_orig = self._build_publish_ad_cfg(base_ad_config)
with (
self._mock_post_submit_dependencies(test_bot, mock_page,
web_await_side_effect = web_await_error,
redirect_recovery_return = None),
pytest.raises(PublishSubmissionUncertainError, match = "submission may have succeeded before failure"),
):
await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
@pytest.mark.asyncio
async def test_publish_ad_confirmation_fallback_from_referrer(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
) -> None:
"""When confirmation URL polling times out, ad ID should be recovered from document.referrer."""
ad_cfg, ad_cfg_orig = self._build_publish_ad_cfg(base_ad_config)
with self._mock_post_submit_dependencies(
test_bot, mock_page,
web_await_side_effect = TimeoutError("confirmation timeout"),
redirect_recovery_return = 99887766,
include_success_mocks = True,
):
await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
assert ad_cfg_orig["id"] == 99887766
@pytest.mark.asyncio
async def test_publish_ad_ignores_stale_referrer_after_timeout(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
) -> None:
"""A stale pre-submit referrer must not recover the previous ad ID for a new publish attempt."""
ad_cfg, ad_cfg_orig = self._build_publish_ad_cfg(base_ad_config)
stale_confirmation_url = "https://www.kleinanzeigen.de/p-anzeige-aufgeben-bestaetigung.html?adId=99887766"
with (
self._mock_post_submit_dependencies(
test_bot,
mock_page,
web_await_side_effect = TimeoutError("confirmation timeout"),
web_execute_side_effect = [stale_confirmation_url, stale_confirmation_url, "var x = 42;"],
mock_redirect_recovery = False,
),
patch("kleinanzeigen_bot.publishing_persistence.persist_published_ad") as mock_persist,
pytest.raises(PublishSubmissionUncertainError, match = "submission may have succeeded before failure"),
):
await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
mock_persist.assert_not_called()
assert ad_cfg_orig["id"] is None
@pytest.mark.asyncio
async def test_publish_ad_confirmation_fallback_when_redirect_happens_after_url_poll(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
) -> None:
"""When the confirmation URL was observed by polling but the page redirected before extraction, the fallback should recover the ad ID."""
ad_cfg, ad_cfg_orig = self._build_publish_ad_cfg(base_ad_config)
# web_await succeeds (confirmation URL was seen during polling), but the page
# redirects before submit_and_confirm_ad can extract the URL, causing IndexError
# in the extraction which falls into the except block and triggers the fallback.
with self._mock_post_submit_dependencies(
test_bot, mock_page,
redirect_recovery_return = 55667788,
include_success_mocks = True,
):
await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
assert ad_cfg_orig["id"] == 55667788
@pytest.mark.asyncio
async def test_publish_ad_confirmation_fallback_from_tracking_raises_uncertain_when_not_found(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
) -> None:
"""When both confirmation URL polling and tracking fallback fail, PublishSubmissionUncertainError should be raised."""
ad_cfg, ad_cfg_orig = self._build_publish_ad_cfg(base_ad_config)
with (
self._mock_post_submit_dependencies(test_bot, mock_page,
web_await_side_effect = TimeoutError("confirmation timeout"),
redirect_recovery_return = None),
pytest.raises(PublishSubmissionUncertainError, match = "submission may have succeeded before failure"),
):
await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
@pytest.mark.asyncio
async def test_publish_ad_confirmation_fallback_helper_failure_still_raises_uncertain(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
) -> None:
"""When the tracking fallback helper itself raises, it should not change retry behavior — PublishSubmissionUncertainError must still be raised."""
ad_cfg, ad_cfg_orig = self._build_publish_ad_cfg(base_ad_config)
with (
self._mock_post_submit_dependencies(test_bot, mock_page,
web_await_side_effect = TimeoutError("confirmation timeout"),
redirect_recovery_side_effect = RuntimeError("browser disconnected")),
pytest.raises(PublishSubmissionUncertainError, match = "submission may have succeeded before failure"),
):
await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
class TestWantedShippingSelection:
"""Orchestration seam test for WANTED shipping delegation.
Verifies that ``publish_ad`` / ``fill_ad_form`` delegates to
``kleinanzeigen_bot.publishing_form.set_shipping_form(self, ad_cfg, mode)``
with the expected bot/ad/mode arguments. Does not validate selector labels
or error behavior — those are covered by ``tests/unit/test_publishing_form.py``.
"""
@pytest.mark.asyncio
async def test_publish_ad_delegates_to_set_shipping_form(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
tmp_path:Path,
) -> None:
"""``publish_ad`` must delegate the shipping step to ``set_shipping_form``."""
test_bot.page = mock_page
test_bot.page.url = "https://www.kleinanzeigen.de/p-anzeige-aufgeben-bestaetigung.html?adId=12345"
ad_cfg = Ad.model_validate(
base_ad_config
| {
"type": "WANTED",
"shipping_type": "SHIPPING",
"shipping_options": [],
"price_type": "NOT_APPLICABLE",
"price": None,
}
)
ad_cfg_orig = ad_cfg.model_dump()
ad_file = str(tmp_path / "ad.yaml")
async def execute_side_effect(script:str) -> Any:
if "window.location.href" in script:
return test_bot.page.url
return None
with (
patch.object(test_bot, "web_open", new_callable = AsyncMock),
patch.object(test_bot, "dismiss_consent_banner", new_callable = AsyncMock),
patch("kleinanzeigen_bot.publishing_form.set_category", new_callable = AsyncMock),
patch("kleinanzeigen_bot.publishing_form.set_pricing_fields", new_callable = AsyncMock),
patch("kleinanzeigen_bot.publishing_form.set_special_attributes", new_callable = AsyncMock),
patch("kleinanzeigen_bot.publishing_form.set_shipping_form", new_callable = AsyncMock) as mock_set_shipping_form,
patch("kleinanzeigen_bot.publishing_form.set_contact_fields", new_callable = AsyncMock),
patch("kleinanzeigen_bot.publishing_form.fill_image_section", new_callable = AsyncMock),
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock),
patch("kleinanzeigen_bot.captcha_flow.check_and_wait_for_captcha", new_callable = AsyncMock),
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
patch.object(test_bot, "_web_find_all_once", new_callable = AsyncMock, return_value = []),
patch.object(test_bot, "web_scroll_page_down", new_callable = AsyncMock),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
patch.object(test_bot, "web_execute", side_effect = execute_side_effect),
patch.object(test_bot, "web_check", new_callable = AsyncMock),
patch.object(test_bot, "web_click", new_callable = AsyncMock),
patch.object(test_bot, "web_find", new_callable = AsyncMock),
):
await test_bot.publish_ad(ad_file, ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.REPLACE)
mock_set_shipping_form.assert_awaited_once_with(test_bot, ad_cfg, AdUpdateStrategy.REPLACE)
class TestAutoPriceReductionDispatch:
"""Price reduction dispatch tests across REPLACE and MODIFY modes."""
@pytest.mark.asyncio
async def test_auto_price_reduction_conditional_on_mode_and_on_update(
self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any], tmp_path:Path
) -> None:
"""Test price reduction dispatch across REPLACE and MODIFY modes.
- REPLACE mode always calls apply_auto_price_reduction.
- MODIFY mode with on_update=false still calls it for restore-first (no cycle advance).
- MODIFY mode with on_update=true calls it with full evaluation and cycle advance.
"""
# Shared ad config with auto price reduction enabled
ad_cfg = Ad.model_validate(
base_ad_config
| {
"id": 12345,
"price": 200,
"auto_price_reduction": {"enabled": True, "strategy": "FIXED", "amount": 50, "min_price": 50, "delay_reposts": 0, "delay_days": 0},
"repost_count": 1,
"price_reduction_count": 0,
"updated_on": "2024-01-01T00:00:00",
"created_on": "2024-01-01T00:00:00",
}
)
ad_cfg.update_content_hash()
ad_cfg_orig = ad_cfg.model_dump()
mock_response = {"statusCode": 200, "statusMessage": "OK", "content": "{}"}
async def mock_web_execute_price_reduction(script:str) -> Any:
if "window.location.href" in script:
return "https://www.kleinanzeigen.de/p-anzeige-aufgeben-bestaetigung.html?adId=12345"
return mock_response
async def mock_web_probe(selector_type:Any, selector_value:str, **_kwargs:Any) -> Any:
if selector_value == "ad-category-path":
marker = MagicMock()
marker.apply = AsyncMock(return_value = "")
return marker
return None
with (
patch("kleinanzeigen_bot.price_reduction.apply_auto_price_reduction") as mock_apply,
patch("kleinanzeigen_bot.delete_flow.delete_ad", new_callable = AsyncMock),
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = mock_web_probe),
patch.object(test_bot, "web_find", new_callable = AsyncMock),
patch.object(test_bot, "web_input", new_callable = AsyncMock),
patch.object(test_bot, "web_click", new_callable = AsyncMock),
patch.object(test_bot, "web_open", new_callable = AsyncMock),
patch.object(test_bot, "web_select", new_callable = AsyncMock),
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False),
patch.object(test_bot, "web_await", new_callable = AsyncMock),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
patch.object(test_bot, "web_execute", side_effect = mock_web_execute_price_reduction),
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = mock_response),
patch.object(test_bot, "web_scroll_page_down", new_callable = AsyncMock),
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = []),
patch.object(test_bot, "_web_find_all_once", new_callable = AsyncMock, return_value = []),
patch("kleinanzeigen_bot.captcha_flow.check_and_wait_for_captcha", new_callable = AsyncMock),
patch("kleinanzeigen_bot.publishing_form.set_contact_fields", new_callable = AsyncMock),
patch("builtins.input", return_value = ""),
patch("kleinanzeigen_bot.utils.misc.ainput", new_callable = AsyncMock, return_value = ""),
):
test_bot.page = MagicMock()
test_bot.page.url = "https://www.kleinanzeigen.de/p-anzeige-aufgeben-bestaetigung.html?adId=12345"
test_bot.config.publishing.delete_old_ads = "BEFORE_PUBLISH"
# --- REPLACE mode: always calls apply_auto_price_reduction ---
await test_bot.publish_ad(str(tmp_path / "ad.yaml"), ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.REPLACE)
mock_apply.assert_called_once()
assert mock_apply.call_args.kwargs["mode"] == AdUpdateStrategy.REPLACE
# --- MODIFY mode with default config (on_update=false): still calls for restore-first ---
mock_apply.reset_mock()
await test_bot.publish_ad(str(tmp_path / "ad.yaml"), ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
mock_apply.assert_called_once()
assert mock_apply.call_args.kwargs["mode"] == AdUpdateStrategy.MODIFY
# --- MODIFY mode with on_update=true: SHOULD call (new conditional behavior) ---
mock_apply.reset_mock()
ad_cfg.auto_price_reduction = AutoPriceReductionConfig(
enabled = True,
strategy = "FIXED",
amount = 50,
min_price = 50,
delay_reposts = 0,
delay_days = 0,
on_update = True,
)
await test_bot.publish_ad(str(tmp_path / "ad.yaml"), ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
mock_apply.assert_called_once()
assert mock_apply.call_args.kwargs["mode"] == AdUpdateStrategy.MODIFY
@pytest.mark.asyncio
async def test_cross_drive_path_fallback_windows(
self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]
) -> None:
"""Test that cross-drive path handling falls back to absolute path on Windows."""
# Create ad config
ad_cfg = Ad.model_validate(
base_ad_config
| {
"updated_on": "2024-01-01T00:00:00",
"created_on": "2024-01-01T00:00:00",
"auto_price_reduction": {"enabled": True, "strategy": "FIXED", "amount": 10, "min_price": 50, "delay_reposts": 0, "delay_days": 0},
"price": 100,
"repost_count": 1,
"price_reduction_count": 0,
}
)
ad_cfg.update_content_hash()
ad_cfg_orig = ad_cfg.model_dump()
# Simulate Windows cross-drive scenario
# Config on D:, ad file on C:
test_bot.config_file_path = str(PureWindowsPath("D:\\project\\config.yaml"))
ad_file = str(PureWindowsPath("C:\\temp\\test_ad.yaml"))
# Create a sentinel exception to abort publish_ad early
class _SentinelException(Exception):
pass
# Track what path argument __apply_auto_price_reduction receives
recorded_path:list[str] = []
def mock_apply_auto_price_reduction(
ad_cfg:Ad,
ad_cfg_orig:dict[str, Any],
ad_file_relative:str,
*,
mode:AdUpdateStrategy = AdUpdateStrategy.REPLACE,
) -> None:
_ = mode
recorded_path.append(ad_file_relative)
raise _SentinelException("Abort early for test")
with (
# Force PureWindowsPath so that the cross-drive relative_to raises
# ValueError deterministically, even on non-Windows runners.
patch("kleinanzeigen_bot.ad_state.Path", PureWindowsPath),
patch("kleinanzeigen_bot.price_reduction.apply_auto_price_reduction", side_effect = mock_apply_auto_price_reduction),
patch.object(test_bot, "web_open", new_callable = AsyncMock),
patch("kleinanzeigen_bot.delete_flow.delete_ad", new_callable = AsyncMock),
):
# Call publish_ad and expect sentinel exception
try:
await test_bot.publish_ad(ad_file, ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.REPLACE)
pytest.fail("Expected _SentinelException to be raised")
except _SentinelException:
# SentinelException is raised by mock_apply_auto_price_reduction
# to abort publish_ad early after recording the path argument.
pass # No cleanup needed — the real workflow would continue here.
# Verify the path argument is the absolute path (fallback behavior)
assert len(recorded_path) == 1
assert recorded_path[0] == ad_file, f"Expected absolute path fallback, got: {recorded_path[0]}"