fix: classify Auth0 post-submit uncertainty (#1181)

## ℹ️ Description

- Link to the related issue(s): Issue #1120
- Adds focused diagnostics/classification for the post-password-submit
Auth0 uncertainty path. This keeps the existing login/captcha/identifier
flow stable while making the new failure mode easier to classify from
logs.

## 📋 Changes Summary

- Classifies final post-submit uncertainty as still on the Auth0
password page, visible Auth0 error text, verification/MFA-like state, or
unknown redirect/session inconclusive state.
- Logs sanitized diagnostic context and raises clearer classified
messages while preserving the existing timeout prefix.
- Adds focused unit tests for the new classifications and snippet
sanitization.
- Updates German translations for new translatable messages.
- No new dependencies or configuration changes.

### ⚙️ Type of Change

- [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
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

* **Bug Fixes**
* Improved handling when transitioning after Auth0 password submission
by using safer, sanitized diagnostic URLs.
* Added clearer, classification-aware timeout messages when the next
verification step can’t be identified.
* Strengthened detection of post-Auth0 states, including inline Auth0
errors and MFA/verification prompts, with more graceful degradation on
probing failures.

* **Tests**
* Expanded unit tests for post-submit state classification, URL
sanitization, and improved timeout/error-message expectations, including
resilience to probe/URL retrieval failures.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jens
2026-06-27 00:50:04 +02:00
committed by GitHub
parent ccae35563a
commit 4acbc09fac
3 changed files with 467 additions and 12 deletions

View File

@@ -54,6 +54,16 @@ _AUTH0_CAPTCHA_CONTAINER_SELECTOR:Final[str] = (
".g-recaptcha"
)
# Post-submit diagnostic selectors — Auth0 inline error indicators on the
# password page. Probed by _classify_post_submit_state() when the password
# submit does not produce a valid post-Auth0 destination.
_AUTH0_POST_SUBMIT_ERROR_SELECTORS:Final[list[tuple[By, str]]] = [
(By.CSS_SELECTOR, "[role='alert']"),
(By.CSS_SELECTOR, ".ulp-input-error-message"),
(By.CSS_SELECTOR, ".ulp-error-info"),
(By.CSS_SELECTOR, "#error-element-password"),
]
async def _click_auth0_submit(web:WebScrapingMixin, *, timeout:float | None = None) -> None:
"""Click the visible Auth0 submit button, avoiding the hidden form-submit button."""
@@ -286,6 +296,118 @@ async def wait_for_auth0_password_step(web:WebScrapingMixin) -> None:
raise AssertionError(_("Auth0 password step not reached (url=%s)") % current_url) from ex
def _diagnostic_url(web:WebScrapingMixin) -> str:
"""Best-effort URL diagnostic; returns ``'unknown'`` on any exception.
Relies on ``current_page_url()`` which already strips query, fragment,
and userinfo from the raw browser URL.
"""
try:
return current_page_url(web)
except Exception: # noqa: BLE001
return "unknown"
async def _classify_post_submit_state(web:WebScrapingMixin) -> str:
"""Best-effort classification of page state after Auth0 password submit.
Probes URL, Auth0 inline error selectors, and high-confidence MFA/
verification signals (gated to non-destination URLs). Never raises —
all callers must treat the result as non-fatal diagnostic context.
Each probe/text-extraction failure uses a local ``try/except`` so the
signal is treated as absent without discarding already-detected facts.
Returns coarse labels only (no raw page text):
"STILL_ON_PASSWORD_PAGE"
"STILL_ON_PASSWORD_PAGE + AUTH0_INLINE_ERROR"
"MFA_DETECTED (ONE_TIME_CODE_INPUT)"
"MFA_DETECTED (SMS_VERIFICATION)"
"UNKNOWN (url=https://kleinanzeigen.de/u/login/password)"
"""
url = _diagnostic_url(web)
if url == "unknown":
return "UNKNOWN (classification_error)"
facts:list[str] = []
# 1) Password-page classification
is_password_page = "/u/login/password" in url
if is_password_page:
facts.append("STILL_ON_PASSWORD_PAGE")
try:
quick_dom = web.timeout("quick_dom")
except Exception: # noqa: BLE001
quick_dom = 5.0
# 2) Auth0 inline error selectors — gated to password page only because
# ``[role='alert']`` is a broad selector that could match unrelated UI.
# Appears as the coarse label ``AUTH0_INLINE_ERROR`` (no raw text).
if is_password_page:
for sel_type, sel_value in _AUTH0_POST_SUBMIT_ERROR_SELECTORS:
try:
element = await web.web_probe(sel_type, sel_value, timeout = quick_dom)
except Exception: # noqa: S112, BLE001
continue
if element is not None:
try:
text = await web.extract_visible_text(element)
except Exception: # noqa: BLE001
text = None
if text and text.strip():
facts.append("AUTH0_INLINE_ERROR")
break
# 3) High-confidence MFA/verification probes.
# Gate: only run when URL is not a known valid Kleinanzeigen destination
# (i.e. still on login/knotenpunkt or an Auth0 challenge page).
if not is_valid_post_auth0_destination(url):
mfa_facts:list[str] = []
# Locale-independent one-time code input field
try:
otc_element = await web.web_probe(
By.CSS_SELECTOR, "input[autocomplete='one-time-code']",
timeout = quick_dom,
)
if otc_element is not None:
mfa_facts.append("ONE_TIME_CODE_INPUT")
except Exception: # noqa: S110, BLE001
pass
# German SMS verification prompt (same text as check_sms_verification)
try:
sms_element = await web.web_probe(
By.TEXT,
"Wir haben dir gerade einen 6-stelligen Code für die Telefonnummer",
timeout = quick_dom,
)
if sms_element is not None:
mfa_facts.append("SMS_VERIFICATION")
except Exception: # noqa: S110, BLE001
pass
# German email verification prompt (same text as check_email_verification)
try:
email_element = await web.web_probe(
By.TEXT,
"Um dein Konto zu schützen haben wir dir eine E-Mail geschickt",
timeout = quick_dom,
)
if email_element is not None:
mfa_facts.append("EMAIL_VERIFICATION")
except Exception: # noqa: S110, BLE001
pass
if mfa_facts:
facts.append(f"MFA_DETECTED ({', '.join(mfa_facts)})")
if facts:
return " + ".join(facts)
return f"UNKNOWN (url={url})"
async def wait_for_post_auth0_submit_transition(web:WebScrapingMixin, *, username:str) -> None:
post_submit_timeout = web.timeout("login_detection")
quick_dom_timeout = web.timeout("quick_dom")
@@ -294,7 +416,7 @@ async def wait_for_post_auth0_submit_transition(web:WebScrapingMixin, *, usernam
try:
await web.web_await(
lambda: is_valid_post_auth0_destination(current_page_url(web)),
lambda: is_valid_post_auth0_destination(_diagnostic_url(web)),
timeout = post_submit_timeout,
timeout_error_message = f"Auth0 post-submit transition did not complete within {post_submit_timeout} seconds",
apply_multiplier = False,
@@ -313,6 +435,7 @@ async def wait_for_post_auth0_submit_transition(web:WebScrapingMixin, *, usernam
return
LOG.debug("Auth0 post-submit verification remained inconclusive; applying bounded fallback pause")
LOG.debug("Post-submit state before fallback sleep: url=%s", _diagnostic_url(web))
await web.web_sleep(min_ms = fallback_min_ms, max_ms = fallback_max_ms)
try:
@@ -321,8 +444,12 @@ async def wait_for_post_auth0_submit_transition(web:WebScrapingMixin, *, usernam
except (TimeoutError, asyncio.TimeoutError):
LOG.debug("Final post-submit login confirmation did not complete within %.1fs", quick_dom_timeout)
current_url = current_page_url(web)
raise TimeoutError(_("Auth0 post-submit verification remained inconclusive (url=%s)") % current_url)
classification = await _classify_post_submit_state(web)
LOG.warning("Auth0 post-submit verification remained inconclusive")
raise TimeoutError(
_("Auth0 post-submit verification remained inconclusive: %s (url=%s)")
% (classification, _diagnostic_url(web))
)
# ---------------------------------------------------------------------------

View File

@@ -135,7 +135,8 @@ kleinanzeigen_bot/login_flow.py:
"Auth0 password step not reached (url=%s)": "Auth0-Passwortschritt nicht erreicht (URL=%s)"
wait_for_post_auth0_submit_transition:
"Auth0 post-submit verification remained inconclusive (url=%s)": "Auth0-Verifikation nach Absenden blieb unklar (URL=%s)"
"Auth0 post-submit verification remained inconclusive": "Auth0-Verifikation nach Absenden blieb unklar"
"Auth0 post-submit verification remained inconclusive: %s (url=%s)": "Auth0-Verifikation nach Absenden blieb unklar: %s (URL=%s)"
fill_login_data_and_send:
"Logging in...": "Anmeldung..."

View File

@@ -2,6 +2,8 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
import asyncio
import inspect
from collections.abc import Callable
from pathlib import Path
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, call, patch
@@ -622,16 +624,26 @@ class TestKleinanzeigenBotAuthentication:
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = [TimeoutError()]) as mock_wait,
patch("kleinanzeigen_bot.login_flow.is_logged_in", new_callable = AsyncMock, side_effect = asyncio.TimeoutError) as mock_is_logged_in,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
patch(
"kleinanzeigen_bot.login_flow._classify_post_submit_state",
new_callable = AsyncMock,
return_value = "UNKNOWN (url=unknown)",
) as mock_classify,
pytest.raises(TimeoutError) as exc_info,
):
with pytest.raises(TimeoutError, match = "Auth0 post-submit verification remained inconclusive"):
await wait_for_post_auth0_submit_transition(test_bot, username = test_bot.config.login.username)
# The classified state must surface in the exception message
assert "Auth0 post-submit verification remained inconclusive" in str(exc_info.value)
assert "UNKNOWN (url=unknown)" in str(exc_info.value)
mock_wait.assert_awaited_once()
assert mock_is_logged_in.await_count == 2
mock_sleep.assert_awaited_once()
assert mock_sleep.await_args is not None
sleep_kwargs = cast(Any, mock_sleep.await_args).kwargs
assert sleep_kwargs["min_ms"] < sleep_kwargs["max_ms"]
mock_classify.assert_awaited_once()
@pytest.mark.asyncio
async def test_click_gdpr_banner_uses_quick_dom_timeout_and_clicks_found_element(self, test_bot:KleinanzeigenBot) -> None:
@@ -969,3 +981,318 @@ class TestKleinanzeigenBotAuthentication:
assert mock_submit.await_count == 2 # first submit + final attempt
mock_ainput.assert_awaited_once() # prompt was shown
mock_sleep.assert_awaited() # grace period sleep happened
class TestClassifyPostSubmitState:
"""Tests for _classify_post_submit_state()."""
@pytest.mark.asyncio
async def test_classify_still_on_password_page(self, test_bot:KleinanzeigenBot) -> None:
"""Should classify STILL_ON_PASSWORD_PAGE when URL contains /u/login/password."""
with (
patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://kleinanzeigen.de/u/login/password"),
patch.object(test_bot, "timeout", return_value = 5.0),
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
):
result = await login_flow._classify_post_submit_state(test_bot)
assert "STILL_ON_PASSWORD_PAGE" in result
assert isinstance(result, str)
@pytest.mark.asyncio
async def test_classify_auth0_error_selector_alert(self, test_bot:KleinanzeigenBot) -> None:
"""Should report AUTH0_INLINE_ERROR when [role='alert'] element has visible text."""
mock_element = MagicMock(spec = Element)
with (
patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://kleinanzeigen.de/u/login/password"),
patch.object(test_bot, "timeout", return_value = 5.0),
patch.object(test_bot, "web_probe", new_callable = AsyncMock) as mock_probe,
patch.object(test_bot, "extract_visible_text", new_callable = AsyncMock, return_value = "Falsches Passwort"),
):
# Return the mock element only for the FIRST probe ([role='alert'])
mock_probe.side_effect = [mock_element, None, None, None]
result = await login_flow._classify_post_submit_state(test_bot)
assert "STILL_ON_PASSWORD_PAGE" in result
assert "AUTH0_INLINE_ERROR" in result
# Coarse labels only — no raw text snippets
assert "Falsches Passwort" not in result
@pytest.mark.asyncio
async def test_classify_auth0_error_selector_error_element_password(self, test_bot:KleinanzeigenBot) -> None:
"""Should report AUTH0_INLINE_ERROR when #error-element-password has visible text."""
mock_element = MagicMock(spec = Element)
with (
patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://kleinanzeigen.de/u/login/password"),
patch.object(test_bot, "timeout", return_value = 5.0),
patch.object(test_bot, "web_probe", new_callable = AsyncMock) as mock_probe,
patch.object(test_bot, "extract_visible_text", new_callable = AsyncMock, return_value = "Password is required"),
):
# Error probes 1-3 miss, 4th (#error-element-password) hits.
# MFA probes (calls 5-7) all miss.
mock_probe.side_effect = [None, None, None, mock_element, None, None, None]
result = await login_flow._classify_post_submit_state(test_bot)
assert "STILL_ON_PASSWORD_PAGE" in result
assert "AUTH0_INLINE_ERROR" in result
assert "Password is required" not in result
@pytest.mark.asyncio
async def test_classify_mfa_one_time_code_input(self, test_bot:KleinanzeigenBot) -> None:
"""Should report MFA_DETECTED when input[autocomplete='one-time-code'] is present."""
mock_element = MagicMock(spec = Element)
with (
patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://login.kleinanzeigen.de/u/mfa"),
patch.object(test_bot, "timeout", return_value = 5.0),
patch.object(test_bot, "web_probe", new_callable = AsyncMock) as mock_probe,
):
# Error selectors are gated to password page only, so only MFA
# probes run. One-time-code probe (1st) hits; SMS and email miss.
mock_probe.side_effect = [mock_element, None, None]
result = await login_flow._classify_post_submit_state(test_bot)
assert "MFA_DETECTED" in result
assert "ONE_TIME_CODE_INPUT" in result
@pytest.mark.asyncio
async def test_classify_mfa_sms_prompt(self, test_bot:KleinanzeigenBot) -> None:
"""Should report SMS_VERIFICATION when German SMS prompt text is present."""
mock_element = MagicMock(spec = Element)
with (
patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://login.kleinanzeigen.de/u/mfa-sms"),
patch.object(test_bot, "timeout", return_value = 5.0),
patch.object(test_bot, "web_probe", new_callable = AsyncMock) as mock_probe,
):
# Error selectors gated to password page. One-time-code probe (1st)
# misses; SMS text probe (2nd) hits; email probe (3rd) misses.
mock_probe.side_effect = [None, mock_element, None]
result = await login_flow._classify_post_submit_state(test_bot)
assert "MFA_DETECTED" in result
assert "SMS_VERIFICATION" in result
@pytest.mark.asyncio
async def test_classify_unknown_fallback(self, test_bot:KleinanzeigenBot) -> None:
"""Should return UNKNOWN when no probe matches and URL is non-password."""
with (
patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://kleinanzeigen.de/meine-anzeigen"),
patch.object(test_bot, "timeout", return_value = 5.0),
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
):
result = await login_flow._classify_post_submit_state(test_bot)
assert result.startswith("UNKNOWN (url=")
assert "kleinanzeigen.de/meine-anzeigen" in result
@pytest.mark.asyncio
async def test_classify_combined_password_page_and_error(self, test_bot:KleinanzeigenBot) -> None:
"""Should preserve multiple facts: URL classification + AUTH0_INLINE_ERROR."""
mock_element = MagicMock(spec = Element)
with (
patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://kleinanzeigen.de/u/login/password"),
patch.object(test_bot, "timeout", return_value = 5.0),
patch.object(test_bot, "web_probe", new_callable = AsyncMock) as mock_probe,
patch.object(test_bot, "extract_visible_text", new_callable = AsyncMock, return_value = "Invalid password"),
):
mock_probe.side_effect = [mock_element, None, None, None]
result = await login_flow._classify_post_submit_state(test_bot)
assert "STILL_ON_PASSWORD_PAGE" in result
assert "AUTH0_INLINE_ERROR" in result
assert "Invalid password" not in result
assert " + " in result
@pytest.mark.asyncio
async def test_classify_never_raises(self, test_bot:KleinanzeigenBot) -> None:
"""Should never raise, even when dependencies throw."""
with (
patch("kleinanzeigen_bot.login_flow.current_page_url", side_effect = RuntimeError("boom")),
):
# Must not propagate the exception
result = await login_flow._classify_post_submit_state(test_bot)
assert result == "UNKNOWN (classification_error)"
# ------------------------------------------------------------------ #
# MFA email verification test
# ------------------------------------------------------------------ #
@pytest.mark.asyncio
async def test_classify_mfa_email_prompt(self, test_bot:KleinanzeigenBot) -> None:
"""Should report EMAIL_VERIFICATION when German email prompt text is present."""
mock_element = MagicMock(spec = Element)
with (
patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://login.kleinanzeigen.de/u/mfa-email"),
patch.object(test_bot, "timeout", return_value = 5.0),
patch.object(test_bot, "web_probe", new_callable = AsyncMock) as mock_probe,
):
# Error selectors gated to password page. OTC probe (1st) misses,
# SMS probe (2nd) misses, email prompt probe (3rd) hits.
mock_probe.side_effect = [None, None, mock_element]
result = await login_flow._classify_post_submit_state(test_bot)
assert "MFA_DETECTED" in result
assert "EMAIL_VERIFICATION" in result
# ------------------------------------------------------------------ #
# Auth0 error text edge cases
# ------------------------------------------------------------------ #
@pytest.mark.asyncio
async def test_classify_auth0_error_blank_text_not_reported(self, test_bot:KleinanzeigenBot) -> None:
"""Auth0 error element with whitespace-only text should not produce AUTH0_INLINE_ERROR."""
mock_element = MagicMock(spec = Element)
with (
patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://kleinanzeigen.de/u/login/password"),
patch.object(test_bot, "timeout", return_value = 5.0),
patch.object(test_bot, "web_probe", new_callable = AsyncMock) as mock_probe,
patch.object(test_bot, "extract_visible_text", new_callable = AsyncMock, return_value = " \n "),
):
mock_probe.side_effect = [mock_element, None, None, None]
result = await login_flow._classify_post_submit_state(test_bot)
assert "STILL_ON_PASSWORD_PAGE" in result
assert "AUTH0_INLINE_ERROR" not in result
# ------------------------------------------------------------------ #
# Behavior tests — probe resilience and selector gating
# ------------------------------------------------------------------ #
@pytest.mark.asyncio
async def test_classify_probe_exception_preserves_facts(self, test_bot:KleinanzeigenBot) -> None:
"""When an error probe raises, password-page fact is preserved."""
with (
patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://kleinanzeigen.de/u/login/password"),
patch.object(test_bot, "timeout", return_value = 5.0),
patch.object(test_bot, "web_probe", new_callable = AsyncMock) as mock_probe,
):
# First error probe raises; remaining error probes miss.
# MFA probes all miss.
mock_probe.side_effect = [RuntimeError("boom"), None, None, None, None, None, None]
result = await login_flow._classify_post_submit_state(test_bot)
assert "STILL_ON_PASSWORD_PAGE" in result
assert "AUTH0_INLINE_ERROR" not in result
@pytest.mark.asyncio
async def test_classify_role_alert_gated_from_non_password_page(self, test_bot:KleinanzeigenBot) -> None:
"""[role='alert'] on non-password URL must not produce AUTH0_INLINE_ERROR."""
mock_element = MagicMock(spec = Element)
with (
patch("kleinanzeigen_bot.login_flow.current_page_url", return_value = "https://kleinanzeigen.de/meine-anzeigen"),
patch.object(test_bot, "timeout", return_value = 5.0),
patch.object(test_bot, "web_probe", new_callable = AsyncMock) as mock_probe,
):
# Error selectors are gated to password page only, so even though
# mock_element is returned for any probe, no error label appears.
# MFA probes are gated to non-destination URLs — meine-anzeigen is
# a valid destination, so no MFA probes run either.
mock_probe.return_value = mock_element
result = await login_flow._classify_post_submit_state(test_bot)
# Verify no probes ran at all — both error and MFA probes are gated
mock_probe.assert_not_awaited()
assert "AUTH0_INLINE_ERROR" not in result
assert result.startswith("UNKNOWN (url=")
@pytest.mark.asyncio
async def test_wait_for_post_auth0_submit_transition_safe_diagnostics(
self, test_bot:KleinanzeigenBot,
) -> None:
"""TimeoutError uses coarse labels and sanitised URL."""
with (
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = [TimeoutError()]),
patch("kleinanzeigen_bot.login_flow.is_logged_in", new_callable = AsyncMock, side_effect = asyncio.TimeoutError),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
patch(
"kleinanzeigen_bot.login_flow._classify_post_submit_state",
new_callable = AsyncMock,
return_value = "STILL_ON_PASSWORD_PAGE + AUTH0_INLINE_ERROR",
),
patch("kleinanzeigen_bot.login_flow._diagnostic_url", return_value = "https://kleinanzeigen.de/u/login/password"),
pytest.raises(TimeoutError) as exc_info,
):
await wait_for_post_auth0_submit_transition(test_bot, username = test_bot.config.login.username)
# Exception carries coarse classification and sanitised URL — no raw text
assert "STILL_ON_PASSWORD_PAGE" in str(exc_info.value)
assert "AUTH0_INLINE_ERROR" in str(exc_info.value)
assert "kleinanzeigen.de/u/login/password" in str(exc_info.value)
assert "Auth0 post-submit verification remained inconclusive" in str(exc_info.value)
@pytest.mark.asyncio
async def test_current_page_url_strips_userinfo(
self, test_bot:KleinanzeigenBot,
) -> None:
"""current_page_url strips userinfo, query, and fragment from URLs."""
# Set up a page with a raw URL containing credentials and query/fragment
test_bot.page = AsyncMock()
test_bot.page.url = "https://user:secret@login.kleinanzeigen.de/u/login/password?state=abc&code=secret#frag"
result = current_page_url(test_bot)
# Sensitive parts must be removed
assert "user:" not in result
assert "secret" not in result
assert "state=" not in result
assert "code=" not in result
assert "#frag" not in result
# Sanitised path and host must be preserved
assert "login.kleinanzeigen.de/u/login/password" in result
@pytest.mark.asyncio
async def test_wait_for_post_auth0_submit_transition_url_failure(
self, test_bot:KleinanzeigenBot,
) -> None:
"""TimeoutError prefix preserved when URL retrieval fails."""
with (
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = [TimeoutError()]),
patch("kleinanzeigen_bot.login_flow.is_logged_in", new_callable = AsyncMock, side_effect = asyncio.TimeoutError),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
patch(
"kleinanzeigen_bot.login_flow._classify_post_submit_state",
new_callable = AsyncMock,
return_value = "STILL_ON_PASSWORD_PAGE",
),
patch("kleinanzeigen_bot.login_flow._diagnostic_url", return_value = "unknown"),
pytest.raises(TimeoutError, match = "Auth0 post-submit verification remained inconclusive"),
):
await wait_for_post_auth0_submit_transition(test_bot, username = test_bot.config.login.username)
@pytest.mark.asyncio
async def test_wait_for_post_auth0_submit_transition_predicate_url_failure(
self, test_bot:KleinanzeigenBot,
) -> None:
"""current_page_url failure in predicate reaches classified TimeoutError."""
async def _call_predicate(
condition:Callable[[], Any] | None = None, *,
timeout:object = None, timeout_error_message:str = "",
apply_multiplier:bool = True,
) -> Any:
assert condition is not None
result = condition()
result = await result if inspect.isawaitable(result) else result
if result:
return result
raise TimeoutError(timeout_error_message or "timed out")
with (
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = _call_predicate),
patch("kleinanzeigen_bot.login_flow.current_page_url", side_effect = RuntimeError("boom")),
patch("kleinanzeigen_bot.login_flow.is_logged_in", new_callable = AsyncMock, side_effect = asyncio.TimeoutError),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
patch("kleinanzeigen_bot.login_flow._classify_post_submit_state", new_callable = AsyncMock, return_value = "STILL_ON_PASSWORD_PAGE"),
pytest.raises(TimeoutError, match = "Auth0 post-submit verification remained inconclusive"),
):
await wait_for_post_auth0_submit_transition(test_bot, username = test_bot.config.login.username)