From 50a4b570cff0cbbfe1b5d1e5c012966ae5b8ad4e Mon Sep 17 00:00:00 2001 From: Jens <1742418+1cu@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:23:26 +0200 Subject: [PATCH] feat: add ad status command (#1177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ℹ️ Description - Link to the related issue(s): Issue # - Adds a CLI status overview inspired by useful lifecycle features from kleinanzeigen-bot-ui, while keeping this project CLI-only. - The new command helps users see local ad state at a glance without replacing the existing verify/APR preflight flow. ## 📋 Changes Summary - Add `kleinanzeigen-bot status` with an ASCII table and summary for local ad states: disabled, draft, changed, due, and published-local. - Add shared pure helpers for content-change and republication-due checks. - Add a small neutral `load_ad_configs()` loader reused by `load_ads()` and `status` without broad loader redesign. - Add German translations and focused unit/guardrail tests. - No schema/config model changes or generated artifacts. ### ⚙️ Type of Change Select the type(s) of change(s) included in this pull request: - [ ] 🐞 Bug fix (non-breaking change which fixes an issue) - [x] ✨ 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: - `pdm run format` passed - `pdm run lint` passed - `pdm run test`: 1315 passed, 4 skipped; coverage 93.18% ## Summary by CodeRabbit * **New Features** * Added a new `status` command to view the current state of configured ads. * Introduced a status summary that shows whether ads are disabled, drafts, changed, due, or published locally. * Added cleaner handling for ad loading and republication checks, improving consistency in displayed results. * **Bug Fixes** * Improved change detection for ads, including better handling when stored data is missing or empty. * Enhanced due-date checks so timing-based status is more reliable. --- src/kleinanzeigen_bot/ad_loading.py | 114 +++++-- src/kleinanzeigen_bot/ad_status.py | 166 +++++++++ src/kleinanzeigen_bot/app.py | 24 +- src/kleinanzeigen_bot/cli.py | 2 + .../resources/translations.de.yaml | 19 ++ src/kleinanzeigen_bot/runtime_config.py | 2 +- tests/unit/test_ad_status.py | 317 ++++++++++++++++++ 7 files changed, 611 insertions(+), 33 deletions(-) create mode 100644 src/kleinanzeigen_bot/ad_status.py create mode 100644 tests/unit/test_ad_status.py diff --git a/src/kleinanzeigen_bot/ad_loading.py b/src/kleinanzeigen_bot/ad_loading.py index 0040657..aed79f0 100644 --- a/src/kleinanzeigen_bot/ad_loading.py +++ b/src/kleinanzeigen_bot/ad_loading.py @@ -96,7 +96,45 @@ def is_valid_ads_selector(ads_selector:str, valid_keywords:set[str]) -> bool: # --------------------------------------------------------------------------- # -# Republication / change detection +# Pure content-hash / due helpers (no I/O, no logging, no mutation) +# --------------------------------------------------------------------------- # + + +def has_ad_content_changed(ad_cfg:Ad, ad_cfg_orig:dict[str, Any]) -> bool: + """Return ``True`` when the ad's stored *content_hash* differs from the + current computed hash. + + Unlike :func:`check_ad_changed`, this function is **pure** — no logging, + no mutation of *ad_cfg_orig*. A missing or empty stored hash is treated + as "not changed". + """ + if ad_cfg.id is None: + return False + stored_hash = ad_cfg_orig.get("content_hash") + if not stored_hash: # None or empty string — hash never computed + return False + current_hash = AdPartial.model_validate(ad_cfg_orig).update_content_hash().content_hash + return current_hash is not None and current_hash != stored_hash + + +def is_ad_due_for_republication(ad_cfg:Ad, *, now:datetime | None = None) -> bool: + """Return ``True`` when *ad_cfg* is due for republication (pure). + + Unlike :func:`check_ad_republication`, this function is **pure** — no + logging, no side effects. Pass *now* for deterministic testing. + """ + if ad_cfg.updated_on is None and ad_cfg.created_on is None: + return True + latest = ad_cfg.updated_on or ad_cfg.created_on + if latest is None: + return True + if now is None: + now = _misc.now() + return (now - latest).days >= ad_cfg.republication_interval + + +# --------------------------------------------------------------------------- # +# Republication / change detection (with logging / mutation) # --------------------------------------------------------------------------- # @@ -108,33 +146,28 @@ def check_ad_republication( ) -> bool: """Return ``True`` when *ad_cfg* is due for republication. + Delegates to :func:`is_ad_due_for_republication` for the pure check, + then adds logging when the ad is **not** due (SKIPPED message). + The interval is measured against :func:`~kleinanzeigen_bot.utils.misc.now` by default; pass *now* to make the function deterministic in tests. """ - if ad_cfg.updated_on: - last_updated_on = ad_cfg.updated_on - elif ad_cfg.created_on: - last_updated_on = ad_cfg.created_on - else: - return True - - if not last_updated_on: + if is_ad_due_for_republication(ad_cfg, now = now): return True + # Log why it was skipped — only reaches here when not due and a date exists if now is None: now = _misc.now() - - ad_age = now - last_updated_on - if ad_age.days < ad_cfg.republication_interval: + latest = ad_cfg.updated_on or ad_cfg.created_on + if latest is not None: + ad_age = now - latest LOG.info( " -> SKIPPED: ad [%s] was last published %d days ago. republication is only required every %s days", ad_file_relative, ad_age.days, ad_cfg.republication_interval, ) - return False - - return True + return False def check_ad_changed( @@ -150,11 +183,10 @@ def check_ad_changed( ``ad_cfg_orig["content_hash"]`` with the freshly computed hash when a change is detected. Callers must be aware of this mutation. """ - if not ad_cfg.id: - # New ads are not considered "changed" + if not has_ad_content_changed(ad_cfg, ad_cfg_orig): return False - # Calculate hash on original config to match what was stored + # Recompute hash for logging and mutation current_hash = AdPartial.model_validate(ad_cfg_orig).update_content_hash().content_hash stored_hash = ad_cfg_orig.get("content_hash") @@ -162,13 +194,10 @@ def check_ad_changed( LOG.debug(" Stored hash: %s", stored_hash) LOG.debug(" Current hash: %s", current_hash) - if stored_hash and current_hash != stored_hash: - LOG.info("Changes detected in ad [%s], will republish", ad_file_relative) - # Update hash in original configuration - ad_cfg_orig["content_hash"] = current_hash - return True - - return False + LOG.info("Changes detected in ad [%s], will republish", ad_file_relative) + # Update hash in original configuration + ad_cfg_orig["content_hash"] = current_hash + return True # --------------------------------------------------------------------------- # @@ -332,6 +361,27 @@ def _prepare_selected_ad_entry( ad_cfg.images = resolve_ad_images(ad_file, ad_cfg.images) +def load_ad_configs( + *, + config_file_path:str, + ad_file_patterns:list[str], + ad_defaults:Any, +) -> list[tuple[str, str, Ad, dict[str, Any]]]: + """Discover, load raw YAML, validate, and apply defaults for every ad file. + + This is a **neutral** loading step — no selector filtering, no category + resolution, no image globbing, no ``_prepare_selected_ad_entry()``. + Returns a sorted list of ``(abspath, relpath, Ad, raw_dict)`` tuples. + """ + ad_files = discover_ad_files(config_file_path, ad_file_patterns) + result:list[tuple[str, str, Ad, dict[str, Any]]] = [] + for ad_file, ad_file_relative in sorted(ad_files.items()): + ad_cfg_orig = _dicts.load_dict(ad_file, "ad") + ad_cfg = load_ad(ad_cfg_orig, ad_defaults) + result.append((ad_file, ad_file_relative, ad_cfg, ad_cfg_orig)) + return result + + def load_ads( *, config_file_path:str, @@ -354,9 +404,13 @@ def load_ads( Tuples of ``(file_path, validated Ad model, original raw data)``. """ LOG.info("Searching for ad config files...") - ad_files = discover_ad_files(config_file_path, ad_file_patterns) - LOG.info(" -> found %s", pluralize("ad config file", ad_files)) - if not ad_files: + loaded = load_ad_configs( + config_file_path = config_file_path, + ad_file_patterns = ad_file_patterns, + ad_defaults = ad_defaults, + ) + LOG.info(" -> found %s", pluralize("ad config file", loaded)) + if not loaded: return [] ids, tokens = _parse_ad_selector(ads_selector) @@ -365,9 +419,7 @@ def load_ads( LOG.info(" | ".join(str(id_) for id_ in ids)) ads:list[tuple[str, Ad, dict[str, Any]]] = [] - for ad_file, ad_file_relative in sorted(ad_files.items()): - ad_cfg_orig:dict[str, Any] = _dicts.load_dict(ad_file, "ad") - ad_cfg:Ad = load_ad(ad_cfg_orig, ad_defaults) + for ad_file, ad_file_relative, ad_cfg, ad_cfg_orig in loaded: # Inactive check runs before numeric ID filtering — an inactive ad # with a matching numeric ID will still be skipped. diff --git a/src/kleinanzeigen_bot/ad_status.py b/src/kleinanzeigen_bot/ad_status.py new file mode 100644 index 0000000..5737022 --- /dev/null +++ b/src/kleinanzeigen_bot/ad_status.py @@ -0,0 +1,166 @@ +# SPDX-FileCopyrightText: © Jens Bergmann and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ +"""Ad status computation and display for the ``status`` CLI command. +This module owns status label mapping, row building, and ASCII table rendering. +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime # noqa: TC003 — used in runtime type annotations +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from . import ad_loading + +if TYPE_CHECKING: + from .model.ad_model import Ad + + +@dataclass(frozen = True, slots = True) +class StatusRow: + """One row in the status table. Rendered by :func:`render_status_rows`.""" + + title:str # ad.title + ad_id:str # "-" if None, else str(ad.id) + status:str # One of: "disabled", "draft", "changed", "due", "published-local" + + +def _translate_status(status:str) -> str: + """Return the translated display label for a status string.""" + if status == "disabled": + return _("disabled") + if status == "draft": + return _("draft") + if status == "changed": + return _("changed") + if status == "due": + return _("due") + if status == "published-local": + return _("published-local") + return status + + +def compute_ad_status( + ad:Ad, + ad_cfg_orig:dict[str, Any], + *, + now:datetime | None = None, +) -> str: + """Map a single :class:`Ad` to a status string. + + Precedence (first match wins): + 1. ``disabled`` — *ad.active* is ``False`` + 2. ``draft`` — *ad.id* is ``None`` + 3. ``changed`` — stored *content_hash* exists, non-empty, and differs + from recomputed hash + 4. ``due`` — both timestamp fields are ``None`` or + *republication_interval* has elapsed + 5. ``published-local`` — fallthrough (has id, not disabled/draft/changed/due) + """ + if not ad.active: + return "disabled" + if ad.id is None: + return "draft" + if ad_loading.has_ad_content_changed(ad, ad_cfg_orig): + return "changed" + if ad_loading.is_ad_due_for_republication(ad, now = now): + return "due" + return "published-local" + + +def build_status_rows( + ads:list[tuple[str, Ad, dict[str, Any]]], + *, + now:datetime | None = None, +) -> list[StatusRow]: + """Build status rows from ad-file / Ad / raw-dict triples.""" + rows:list[StatusRow] = [] + for _ad_file, ad_cfg, ad_cfg_orig in ads: + status = compute_ad_status(ad_cfg, ad_cfg_orig, now = now) + rows.append( + StatusRow( + title = ad_cfg.title, + ad_id = "-" if ad_cfg.id is None else str(ad_cfg.id), + status = status, + ) + ) + return rows + + +def render_status_rows(rows:list[StatusRow]) -> str: + """Format status rows into an ASCII table string.""" + if not rows: + return "" + + h_id = _("Ad ID") + h_title = _("Title") + h_status = _("Status") + + col_id = max(len(h_id), max((len(r.ad_id) for r in rows), default = 0)) + + # Translated labels for column width calculation. + # Uses ``_translate_status()`` which contains explicit ``_("...")`` calls + # that the translation-coverage scanner can find. + translated_statuses = [ + _translate_status("disabled"), + _translate_status("draft"), + _translate_status("changed"), + _translate_status("due"), + _translate_status("published-local"), + ] + col_status = max(len(h_status), *[len(s) for s in translated_statuses], 0) + + # Title width is data-driven, but clamp to at least header width + col_title = max(len(h_title), max((len(r.title) for r in rows), default = 0)) + + sep = ( + "+" + + "-" * (col_id + 2) + + "+" + + "-" * (col_title + 2) + + "+" + + "-" * (col_status + 2) + + "+" + ) + header = ( + "| " + + h_id.ljust(col_id) + + " | " + + h_title.ljust(col_title) + + " | " + + h_status.ljust(col_status) + + " |" + ) + + lines:list[str] = [sep, header, sep] + + for r in rows: + label = _translate_status(r.status) + lines.append( + "| " + + r.ad_id.ljust(col_id) + + " | " + + r.title.ljust(col_title) + + " | " + + label.ljust(col_status) + + " |" + ) + + lines.append(sep) + + # Summary line + counts:dict[str, int] = {} + for r in rows: + counts[r.status] = counts.get(r.status, 0) + 1 + + summary_parts:list[str] = [ + f"{_translate_status(label)}: {counts[label]}" + for label in ("disabled", "draft", "changed", "due", "published-local") + if label in counts + ] + + summary = _("Summary: %s (%d total)") % (", ".join(summary_parts), len(rows)) + lines.append(summary) + + return "\n".join(lines) + "\n" diff --git a/src/kleinanzeigen_bot/app.py b/src/kleinanzeigen_bot/app.py index 87a9832..27b588b 100644 --- a/src/kleinanzeigen_bot/app.py +++ b/src/kleinanzeigen_bot/app.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, Final, cast import certifi -from . import ad_loading, delete_flow, download_flow, extend_flow +from . import ad_loading, ad_status, delete_flow, download_flow, extend_flow from . import ad_state as _ad_state from . import login_flow as _login_flow from . import price_reduction as _price_reduction @@ -137,6 +137,8 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 self._handle_update_check() case "update-content-hash": self._handle_update_content_hash() + case "status": + self._handle_status() case "publish": await self._handle_publish() case "update": @@ -246,6 +248,26 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 LOG.info("DONE: No active ads found.") LOG.info("############################################") + def _handle_status(self) -> None: + """Show status overview of all local ads.""" + self._bootstrap_runtime() + self._check_for_updates() + + loaded = ad_loading.load_ad_configs( + config_file_path = self.config_file_path, + ad_file_patterns = self.config.ad_files, + ad_defaults = self.config.ad_defaults, + ) + if not loaded: + LOG.info("No ad files found.") + return + + now = _misc.now() + ads_for_status = [(abspath, ad_cfg, raw) for abspath, _relpath, ad_cfg, raw in loaded] + rows = ad_status.build_status_rows(ads_for_status, now = now) + output = ad_status.render_status_rows(rows) + print(output) + async def _handle_publish(self) -> None: self._bootstrap_runtime() self._check_for_updates() diff --git a/src/kleinanzeigen_bot/cli.py b/src/kleinanzeigen_bot/cli.py index 9c13a5b..44ead8f 100644 --- a/src/kleinanzeigen_bot/cli.py +++ b/src/kleinanzeigen_bot/cli.py @@ -80,6 +80,7 @@ def _help_text() -> str: "geändert" gelten und neu veröffentlicht werden. create-config - Erstellt eine neue Standard-Konfigurationsdatei, falls noch nicht vorhanden diagnose - Diagnostiziert Browser-Verbindungsprobleme und zeigt Troubleshooting-Informationen + status - Zeigt den Status der Anzeigen an -- help - Zeigt diese Hilfe an (Standardbefehl) version - Zeigt die Version der Anwendung an @@ -135,6 +136,7 @@ def _help_text() -> str: use this after changing config.yaml/ad_defaults to avoid every ad being marked "changed" and republished create-config - creates a new default configuration file if one does not exist diagnose - diagnoses browser connection issues and shows troubleshooting information + status - shows status overview of ads -- help - displays this help (default command) version - displays the application version diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml index 22780a6..6d6422e 100644 --- a/src/kleinanzeigen_bot/resources/translations.de.yaml +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -109,6 +109,9 @@ kleinanzeigen_bot/app.py: _handle_download: "Invalid --ads selector: \"%s\". Valid values: comma-separated keywords (all, new) or numeric IDs.": "Ungültiger --ads-Selektor: \"%s\". Gültige Werte: kommagetrennte Schlüsselwörter (all, new) oder numerische IDs." + _handle_status: + "No ad files found.": "Keine Anzeigendateien gefunden." + ################################################# kleinanzeigen_bot/login_flow.py: ################################################# @@ -442,6 +445,22 @@ kleinanzeigen_bot/download_flow.py: _download_ad_with_resolved_state: "Ad %d found in overview but not in published profile. Saving as inactive.": "Anzeige %d wurde in der Übersicht gefunden, aber nicht im veröffentlichten Profil. Speichere als inaktiv." +################################################# +kleinanzeigen_bot/ad_status.py: +################################################# + _translate_status: + "disabled": "Deaktiviert" + "draft": "Entwurf" + "changed": "Geändert" + "due": "Fällig" + "published-local": "Veröffentlicht (lokal)" + + render_status_rows: + "Ad ID": "Anzeigen-ID" + "Title": "Titel" + "Status": "Status" + "Summary: %s (%d total)": "Zusammenfassung: %s (%d insgesamt)" + ################################################# kleinanzeigen_bot/ad_loading.py: ################################################# diff --git a/src/kleinanzeigen_bot/runtime_config.py b/src/kleinanzeigen_bot/runtime_config.py index fd844ec..ac8f4bc 100644 --- a/src/kleinanzeigen_bot/runtime_config.py +++ b/src/kleinanzeigen_bot/runtime_config.py @@ -36,7 +36,7 @@ WORKSPACE_FREE_COMMANDS:Final[frozenset[str]] = frozenset({"help", "version", "c VALID_COMMANDS:Final[frozenset[str]] = frozenset({ "help", "version", "create-config", "diagnose", "verify", "update-check", "update-content-hash", - "publish", "update", "delete", "extend", "download", + "publish", "status", "update", "delete", "extend", "download", }) diff --git a/tests/unit/test_ad_status.py b/tests/unit/test_ad_status.py new file mode 100644 index 0000000..18f9667 --- /dev/null +++ b/tests/unit/test_ad_status.py @@ -0,0 +1,317 @@ +# 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 ad_status module — pure computation, no disk I/O in unit tests.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any +from unittest.mock import patch + +import pytest + +from kleinanzeigen_bot.ad_status import ( + StatusRow, + build_status_rows, + compute_ad_status, + render_status_rows, +) +from kleinanzeigen_bot.app import KleinanzeigenBot +from kleinanzeigen_bot.cli import parse_args +from kleinanzeigen_bot.model.ad_model import Ad, AdPartial +from kleinanzeigen_bot.model.config_model import Config +from kleinanzeigen_bot.runtime_config import VALID_COMMANDS, RuntimeState +from kleinanzeigen_bot.utils import xdg_paths + +pytestmark = pytest.mark.unit + +_TZ = timezone.utc + + +def _now() -> datetime: + """Return a timezone-aware 'now' for reproducible tests.""" + return datetime.now(tz = _TZ) + + +def days_ago(n:int) -> datetime: + """Return *n* days before :func:`_now`.""" + return _now() - timedelta(days = n) + + +def _ad(**overrides:Any) -> Ad: + """Helper: build an Ad with all required fields.""" + defaults:dict[str, Any] = { + "active": True, + "title": "test ad title", + "type": "OFFER", + "price": 10, + "description": "desc", + "category": "Möbel", + "shipping_type": "PICKUP", + "price_type": "FIXED", + "sell_directly": False, + "contact": {"name": "Test", "zipcode": "12345"}, + "republication_interval": 7, + } + defaults.update(overrides) + return Ad(**defaults) + + +def _raw(**overrides:Any) -> dict[str, Any]: + """Helper: build a raw YAML dict with required fields.""" + d:dict[str, Any] = { + "title": "test ad title", + "description": "desc", + "category": "Möbel", + } + d.update(overrides) + return d + + +def _content_hash(raw:dict[str, Any]) -> str: + """Compute the actual content_hash for a raw dict.""" + result = AdPartial.model_validate(raw).update_content_hash().content_hash + assert result is not None + return result + + +# --------------------------------------------------------------------------- # +# compute_ad_status — precedence +# --------------------------------------------------------------------------- # + + +def test_disabled() -> None: + """Disabled beats everything, even with id and matching hash.""" + ad = _ad(active = False, id = 123, content_hash = "abc") + raw = _raw(content_hash = "abc") + assert compute_ad_status(ad, raw) == "disabled" + + +def test_draft() -> None: + """Draft when id is None.""" + ad = _ad(active = True, id = None) + raw = _raw() + assert compute_ad_status(ad, raw) == "draft" + + +def test_changed() -> None: + """Changed when stored hash exists, non-empty, and differs.""" + ad = _ad(active = True, id = 1, content_hash = "old_hash") + raw = _raw(content_hash = "old_hash", title = "different title to trigger hash change") + result = compute_ad_status(ad, raw) + assert result == "changed" + + +def test_changed_no_hash_not_changed() -> None: + """Missing stored hash is NOT changed — falls through.""" + ad = _ad(active = True, id = 1, content_hash = None, created_on = _now()) + raw = _raw() + result = compute_ad_status(ad, raw, now = _now()) + assert result != "changed" + assert result == "published-local" + + +def test_changed_no_id_not_changed() -> None: + """No id means draft before hash check.""" + ad = _ad(active = True, id = None, content_hash = "some") + raw = _raw(content_hash = "some") + assert compute_ad_status(ad, raw) == "draft" + + +def test_changed_empty_hash_not_changed() -> None: + """Empty string stored hash is NOT changed (same as missing).""" + ad = _ad(active = True, id = 1, content_hash = "", created_on = _now()) + raw = _raw(content_hash = "") + result = compute_ad_status(ad, raw, now = _now()) + assert result == "published-local" + + +def test_due_interval_elapsed() -> None: + """Due when republication_interval elapsed since created_on.""" + raw = _raw() + ch = _content_hash(raw) + ad = _ad( + active = True, + id = 1, + content_hash = ch, + created_on = days_ago(30), + republication_interval = 7, + ) + result = compute_ad_status(ad, raw, now = _now()) + assert result == "due" + + +def test_due_no_dates() -> None: + """Due when both updated_on and created_on are None.""" + raw = _raw() + ch = _content_hash(raw) + ad = _ad( + active = True, + id = 1, + content_hash = ch, + updated_on = None, + created_on = None, + republication_interval = 7, + ) + result = compute_ad_status(ad, raw, now = _now()) + assert result == "due" + + +def test_due_not_due() -> None: + """Not due when republication_interval has not elapsed.""" + raw = _raw() + ch = _content_hash(raw) + ad = _ad( + active = True, + id = 1, + content_hash = ch, + created_on = days_ago(1), + republication_interval = 7, + ) + result = compute_ad_status(ad, raw, now = _now()) + assert result == "published-local" + + +def test_published_local() -> None: + """published-local when id exists and nothing else applies.""" + raw = _raw() + ch = _content_hash(raw) + ad = _ad( + active = True, + id = 1, + content_hash = ch, + created_on = _now(), + republication_interval = 7, + ) + result = compute_ad_status(ad, raw, now = _now()) + assert result == "published-local" + + +# --------------------------------------------------------------------------- # +# build_status_rows +# --------------------------------------------------------------------------- # + + +def test_build_status_rows() -> None: + """build_status_rows produces a StatusRow per ad.""" + ads:list[tuple[str, Ad, dict[str, Any]]] = [ + ("ads/one.yaml", _ad(active = False, id = 123), _raw()), + ("ads/two.yaml", _ad(active = True, id = None), _raw()), + ] + rows = build_status_rows(ads, now = _now()) + assert len(rows) == 2 + assert rows[0].status == "disabled" + assert rows[1].status == "draft" + + +# --------------------------------------------------------------------------- # +# render_status_rows +# --------------------------------------------------------------------------- # + + +class TestRenderStatusRows: + def test_empty(self) -> None: + assert not render_status_rows([]) + + def test_headers_and_rows(self) -> None: + rows = [ + StatusRow(title = "Ad A", ad_id = "-", status = "draft"), + StatusRow(title = "Ad B", ad_id = "123", status = "published-local"), + ] + output = render_status_rows(rows) + + # Contains ASCII table borders + assert "+" in output + assert "-" in output + assert "|" in output + + # Contains headers + assert "Ad ID" in output + assert "Title" in output + assert "Status" in output + + # Contains row data + assert "-" in output + assert "123" in output + assert "Ad A" in output + assert "Ad B" in output + + # Contains summary line + assert "draft:" in output.casefold() + assert "published-local:" in output.casefold() + + # Summary ends with total count + assert "total" in output.casefold() or "gesamt" in output.casefold() + + +# --------------------------------------------------------------------------- # +# Guardrail: status does not call load_ads or open browser +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_status_guardrails( + monkeypatch:pytest.MonkeyPatch, + tmp_path:Any, +) -> None: + """Status calls _check_for_updates but NOT load_ads / browser login.""" + bot = KleinanzeigenBot() + bot.config_file_path = str(tmp_path / "config.yaml") + workspace = xdg_paths.Workspace.for_config(tmp_path / "config.yaml", "kleinanzeigen-bot") + dummy_config = Config() + + update_called:list[bool] = [] + load_ads_called:list[bool] = [] + browser_called:list[bool] = [] + + def _track_update(*_args:Any, **_kwargs:Any) -> None: + update_called.append(True) + + def _track_load_ads(*_args:Any, **_kwargs:Any) -> list[Any]: + load_ads_called.append(True) + return [] + + def _track_browser(*_args:Any, **_kwargs:Any) -> None: + browser_called.append(True) + + with ( + patch("kleinanzeigen_bot.runtime_config.resolve_workspace", return_value = workspace), + patch( + "kleinanzeigen_bot.runtime_config.load_config", + return_value = RuntimeState( + config = dummy_config, categories = {}, timing_collector = None, + ), + ), + patch("kleinanzeigen_bot.runtime_config.configure_file_logging", return_value = None), + patch("kleinanzeigen_bot.runtime_config.apply_browser_config"), + patch("kleinanzeigen_bot.update_checker.UpdateChecker"), + patch.object(bot, "_check_for_updates", side_effect = _track_update), + patch.object(bot, "load_ads", side_effect = _track_load_ads), + patch.object(bot, "create_browser_session", side_effect = _track_browser), + patch.object(bot, "login", side_effect = _track_browser), + patch.object(bot, "close_browser_session"), + patch("kleinanzeigen_bot.ad_loading.load_ad_configs", return_value = []), + patch("kleinanzeigen_bot.ad_status.render_status_rows", return_value = ""), + ): + await bot.run(["app", "status"]) + + assert update_called, "status must call _check_for_updates()" + assert not load_ads_called, "status must not call load_ads()" + assert not browser_called, "status must not open browser or login" + + +# --------------------------------------------------------------------------- # +# Parser / dispatch +# --------------------------------------------------------------------------- # + + +def test_status_is_valid_command() -> None: + """Status is recognised by the parser.""" + parsed = parse_args(["app", "status"]) + assert parsed.command == "status" + + +def test_status_in_runtime_config() -> None: + """Status is listed in VALID_COMMANDS.""" + assert "status" in VALID_COMMANDS