mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-10-09 15:21:20 +02:00
feat: list online account ads (#1321)
## ℹ️ Description Add a `list` command to inspect the authenticated account's online ads, including IDs, titles, status and expiry dates. It fetches account pages without loading local ad definitions or modifying listings. Split from #1309 as the independent online-listing feature. No separate issue. ## 📋 Changes Summary - Register and dispatch `list`, and document it in CLI help and README. - Fetch all account pages in strict mode so incomplete or malformed pagination produces an error instead of a partial listing. - Render translated labels/status and handle missing optional fields; close the browser session on success or failure. - Use English gettext source text and honor an explicitly requested help language, addressing the CodeRabbit comment in #1309. - Include the explicit-return and test-subclass fixes for the two CodeQL comments from #1309. No dependencies on the other contributions and no new project dependencies. ### ⚙️ Type of Change - [ ] 🐞 Bug fix - [x] ✨ New feature - [ ] 💥 Breaking change (changes that might break existing user setups, scripts, or configurations) ## ✅ Checklist - [x] I have reviewed my changes to ensure they meet the project's standards. - [x] I have tested the changed behavior with targeted standalone checks and the combined-series `pdm run test` described below. - [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. Standalone `pdm run format` and `pdm run lint` pass. Listing, CLI and translation tests pass: 60 tests. The generated schema, default configuration and README verification passes. Full combined-series validation on upstream main `e4bca13` with Python 3.12 and Windows/Edge: `pdm run format`, `pdm run lint`, `pdm run test` and generated-artifact verification pass; 1,976 tests passed, 2 skipped, 93.81% coverage. This full-suite evidence belongs to the integration of all seven contributions. Standalone targeted evidence for this PR is listed above. Browser fixtures and account responses are local or mocked; tests do not contact Kleinanzeigen. 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 * **New Features** * Added a `list` command to view online ads with their ID, title, status, and expiry date. The command does not download or modify listings or local ad files. * Added localized output in English and German, including messages for empty lists. * The command reports an error if it cannot retrieve the complete listing. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Jens <1742418+1cu@users.noreply.github.com>
This commit is contained in:
1 parent
fc1b97774c
commit
ab79e10c93
7 files changed
+295
-4
No files matched your search
@@ -31,6 +31,7 @@
|
||||
- **Smart Republishing**: Automatically republish listings at configurable intervals to keep them at the top of search results
|
||||
- **Bulk Management**: Update or delete multiple listings at once
|
||||
- **Download Listings**: Download existing listings from your profile to local configuration files
|
||||
- **List Online Listings**: View current account listings with IDs, status, and expiry dates
|
||||
- **Extend Listings**: Extend ads close to expiry to keep watchers/savers and preserve the monthly ad quota
|
||||
- **Browser Automation**: Uses Chromium-based browsers (Chrome, Edge, Chromium) for reliable automation
|
||||
- **Flexible Configuration**: Configure defaults once, override per listing as needed
|
||||
@@ -219,6 +220,7 @@ Commands:
|
||||
create-config - creates a new default configuration file if one does not exist
|
||||
diagnose - diagnoses browser connection issues and shows troubleshooting information
|
||||
status - shows ad status and APR preview details
|
||||
list - lists current online ads with ID, title, state, and expiry date
|
||||
--
|
||||
help - displays this help (default command)
|
||||
version - displays the application version
|
||||
@@ -263,6 +265,12 @@ Options:
|
||||
```
|
||||
<!-- readme-usage:generated:end -->
|
||||
|
||||
`kleinanzeigen-bot list` logs in and reads your current online listings, showing
|
||||
each listing's ID, title, status, and expiry date when available. It does not
|
||||
download listings or change them. In a source checkout, use `pdm run app list`.
|
||||
This differs from `status`, which describes only local ad files. If the online
|
||||
list cannot be fetched completely, `list` fails instead of showing a partial list.
|
||||
|
||||
`publish` and `update` process the remaining ads when an individual ad fails, then
|
||||
exit with status `1` if any ads failed. Visible form-validation errors include the
|
||||
field labels and messages; correct the affected ad configuration before retrying.
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, Final, cast
|
||||
|
||||
import certifi
|
||||
|
||||
from . import ad_loading, ad_status, delete_flow, download_flow, extend_flow, reserve_flow
|
||||
from . import ad_loading, ad_status, delete_flow, download_flow, extend_flow, published_ads, reserve_flow
|
||||
from . import login_flow as _login_flow
|
||||
from . import publishing_workflow as _publishing_workflow
|
||||
from . import runtime_config as _runtime_config
|
||||
@@ -89,6 +89,7 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
return self._workspace_or_raise().state_dir / "update_check_state.json"
|
||||
|
||||
async def run(self, args:list[str]) -> None:
|
||||
"""Parse CLI options, dispatch the requested command, and close browser resources."""
|
||||
_cli = importlib.import_module("kleinanzeigen_bot.cli")
|
||||
parsed = _cli.parse_args(args)
|
||||
self.command = parsed.command
|
||||
@@ -138,6 +139,8 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
self._handle_update_content_hash()
|
||||
case "status":
|
||||
self._handle_status()
|
||||
case "list":
|
||||
await self._handle_list()
|
||||
case "publish":
|
||||
await self._handle_publish()
|
||||
case "update":
|
||||
@@ -264,6 +267,13 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
output = ad_status.render_status_rows(rows, color = use_color)
|
||||
print(output)
|
||||
|
||||
async def _handle_list(self) -> None:
|
||||
"""Show current online ads without loading or changing local ad files."""
|
||||
self._bootstrap_runtime()
|
||||
await self._open_logged_in_browser()
|
||||
ads = await published_ads.fetch_published_ads(self, self.root_url, strict = True)
|
||||
print(published_ads.render_published_ads(ads))
|
||||
|
||||
async def _handle_publish(self) -> None:
|
||||
self._bootstrap_runtime()
|
||||
self._check_for_updates()
|
||||
|
||||
@@ -19,6 +19,7 @@ import signal
|
||||
import sys
|
||||
import textwrap
|
||||
from dataclasses import dataclass
|
||||
from gettext import gettext as _
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
@@ -139,8 +140,15 @@ def _help_executable() -> str:
|
||||
|
||||
|
||||
def help_text(*, executable:str | None = None, language:str | None = None) -> str:
|
||||
"""Render CLI help in the requested language without changing the current locale."""
|
||||
exe = executable if executable is not None else _help_executable()
|
||||
lang = language if language is not None else get_current_locale().language
|
||||
current_locale = get_current_locale()
|
||||
lang = language if language is not None else current_locale.language
|
||||
try:
|
||||
set_current_locale(Locale(lang))
|
||||
list_description = _("lists current online ads with ID, title, state, and expiry date")
|
||||
finally:
|
||||
set_current_locale(current_locale)
|
||||
if lang == "de":
|
||||
return textwrap.dedent(
|
||||
f"""\
|
||||
@@ -162,6 +170,7 @@ def help_text(*, executable:str | None = None, language:str | None = None) -> st
|
||||
create-config - Erstellt eine neue Standard-Konfigurationsdatei, falls noch nicht vorhanden
|
||||
diagnose - Diagnostiziert Browser-Verbindungsprobleme und zeigt Troubleshooting-Informationen
|
||||
status - Zeigt Anzeigenstatus und APR-Vorschau an
|
||||
list - {list_description}
|
||||
--
|
||||
help - Zeigt diese Hilfe an (Standardbefehl)
|
||||
version - Zeigt die Version der Anwendung an
|
||||
@@ -226,6 +235,7 @@ def help_text(*, executable:str | None = None, language:str | None = None) -> st
|
||||
create-config - creates a new default configuration file if one does not exist
|
||||
diagnose - diagnoses browser connection issues and shows troubleshooting information
|
||||
status - shows ad status and APR preview details
|
||||
list - {list_description}
|
||||
--
|
||||
help - displays this help (default command)
|
||||
version - displays the application version
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SPDX-FileCopyrightText: © Jens Bergmann and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
"""Published ads fetching with API pagination."""
|
||||
"""Published ads fetching with API pagination and online list display."""
|
||||
|
||||
import json
|
||||
from gettext import gettext as _
|
||||
@@ -16,6 +16,40 @@ type PublishedAd = dict[str, Any]
|
||||
"""A raw published ad entry from the Kleinanzeigen manage-ads JSON API."""
|
||||
|
||||
|
||||
def _published_ad_state_label(state:Any) -> str:
|
||||
"""Translate known account states and preserve unknown or missing API values."""
|
||||
match state:
|
||||
case "active":
|
||||
return _("active")
|
||||
case "paused":
|
||||
# The API reports a reserved ad as "paused" (see reserve_flow.STATE_RESERVED).
|
||||
return _("reserved")
|
||||
case "inactive":
|
||||
return _("inactive")
|
||||
return str(state) if state is not None else "-"
|
||||
|
||||
|
||||
def render_published_ads(ads:list[PublishedAd]) -> str:
|
||||
"""Render current account ads while preserving unknown states and server dates."""
|
||||
if not ads:
|
||||
return _("No online ads found.")
|
||||
|
||||
id_label = _("ID")
|
||||
title_label = _("title")
|
||||
status_label = _("status")
|
||||
expiry_label = _("expires")
|
||||
blocks = [
|
||||
"\n".join([
|
||||
f"{id_label}: {ad['id']}",
|
||||
f" {title_label}: {ad.get('title') or '-'}",
|
||||
f" {status_label}: {_published_ad_state_label(ad.get('state'))}",
|
||||
f" {expiry_label}: {ad.get('endDate') or '-'}",
|
||||
])
|
||||
for ad in ads
|
||||
]
|
||||
return "\n\n".join(blocks)
|
||||
|
||||
|
||||
def ad_matches_id(ad:PublishedAd, target_id:int | None) -> bool:
|
||||
"""Check if a published ad matches the given target ID.
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ kleinanzeigen_bot/captcha_flow.py:
|
||||
#################################################
|
||||
kleinanzeigen_bot/cli.py:
|
||||
#################################################
|
||||
help_text:
|
||||
"lists current online ads with ID, title, state, and expiry date": "Zeigt aktuelle Online-Anzeigen mit ID, Titel, Status und Ablaufdatum an"
|
||||
|
||||
parse_args:
|
||||
"Use --help to display available options.": "Mit --help können die verfügbaren Optionen angezeigt werden."
|
||||
"Unknown command: %s": "Unbekannter Befehl: %s"
|
||||
@@ -240,6 +243,18 @@ kleinanzeigen_bot/publishing_workflow.py:
|
||||
#################################################
|
||||
kleinanzeigen_bot/published_ads.py:
|
||||
#################################################
|
||||
_published_ad_state_label:
|
||||
"active": "aktiv"
|
||||
"reserved": "reserviert"
|
||||
"inactive": "inaktiv"
|
||||
|
||||
render_published_ads:
|
||||
"No online ads found.": "Keine Online-Anzeigen gefunden."
|
||||
"ID": "ID"
|
||||
"title": "Titel"
|
||||
"status": "Status"
|
||||
"expires": "Ablaufdatum"
|
||||
|
||||
fetch_published_ads:
|
||||
"Stopping pagination after %s pages to avoid infinite loop": "Stoppe die Seitenaufschaltung nach %s Seiten, um eine Endlosschleife zu vermeiden"
|
||||
"Pagination request failed on page %s: %s": "Seitenabfrage auf Seite %s fehlgeschlagen: %s"
|
||||
|
||||
@@ -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", "status", "update", "delete", "extend", "download",
|
||||
"publish", "status", "list", "update", "delete", "extend", "download",
|
||||
"reserve", "activate",
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
# SPDX-FileCopyrightText: © Pommel4711 and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
"""Read-only online listing tests using fake browser responses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
from kleinanzeigen_bot import published_ads
|
||||
from kleinanzeigen_bot.app import KleinanzeigenBot
|
||||
from kleinanzeigen_bot.cli import help_text, parse_args
|
||||
from kleinanzeigen_bot.published_ads import PublishedAdsFetchIncompleteError
|
||||
from kleinanzeigen_bot.runtime_config import VALID_COMMANDS
|
||||
from kleinanzeigen_bot.utils import i18n
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
|
||||
from kleinanzeigen_bot.model.ad_model import Ad
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
type PageResponse = dict[str, Any] | TimeoutError
|
||||
|
||||
|
||||
class FakeListBot(KleinanzeigenBot):
|
||||
"""Exercise the real CLI dispatch and pagination without real browser or file loading."""
|
||||
|
||||
def __init__(self, responses:list[PageResponse]) -> None:
|
||||
"""Initialize canned API responses and track the read-only session lifecycle."""
|
||||
super().__init__()
|
||||
self.responses = responses
|
||||
self.requests:list[str] = []
|
||||
self.bootstrapped = False
|
||||
self.logged_in = False
|
||||
self.closed = False
|
||||
|
||||
def _bootstrap_runtime(self) -> None:
|
||||
"""Record runtime initialization before simulated account access."""
|
||||
self.bootstrapped = True
|
||||
|
||||
def _check_for_updates(self) -> None:
|
||||
"""Fail if listing attempts an unrelated update-check request."""
|
||||
pytest.fail("Online listing must not perform an update-check request")
|
||||
|
||||
async def _open_logged_in_browser(self) -> None:
|
||||
"""Record simulated login only after runtime initialization."""
|
||||
assert self.bootstrapped
|
||||
self.logged_in = True
|
||||
|
||||
async def close_browser_session(self) -> None:
|
||||
"""Record browser cleanup and clear the simulated login state."""
|
||||
self.closed = True
|
||||
self.logged_in = False
|
||||
|
||||
async def web_request(
|
||||
self,
|
||||
url:str,
|
||||
method:str = "GET",
|
||||
valid_response_codes:int | Iterable[int] = 200,
|
||||
headers:dict[str, str] | None = None,
|
||||
) -> Any:
|
||||
"""Return canned pages while rejecting unexpected requests or account mutations."""
|
||||
assert self.logged_in, "Online listing requires an authenticated session"
|
||||
assert method == "GET", "Online listing must not mutate account data"
|
||||
expected_page = len(self.requests) + 1
|
||||
assert url == f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT&pageNum={expected_page}"
|
||||
self.requests.append(url)
|
||||
response = self.responses[expected_page - 1]
|
||||
if isinstance(response, TimeoutError):
|
||||
raise response
|
||||
return response
|
||||
|
||||
def load_ads(self, *, ignore_inactive:bool = True, exclude_ads_with_id:bool = True) -> list[tuple[str, Ad, dict[str, Any]]]:
|
||||
"""Fail if online listing reads local ad definitions."""
|
||||
pytest.fail("Online listing must not load local ads")
|
||||
|
||||
|
||||
def _response(ads:list[published_ads.PublishedAd], page:int = 1, last:int = 1) -> dict[str, Any]:
|
||||
"""Encode one account page with API-style pagination metadata."""
|
||||
paging:dict[str, int] = {"pageNum": page, "last": last}
|
||||
if page < last:
|
||||
paging["next"] = page + 1
|
||||
return {"content": json.dumps({"ads": ads, "paging": paging})}
|
||||
|
||||
|
||||
def _snapshot_files(directory:Path) -> dict[Path, bytes]:
|
||||
"""Capture workspace contents to detect accidental local changes."""
|
||||
return {path.relative_to(directory): path.read_bytes() for path in directory.rglob("*") if path.is_file()}
|
||||
|
||||
|
||||
def _list_args(tmp_path:Path) -> list[str]:
|
||||
"""Build CLI arguments using an isolated portable workspace."""
|
||||
return ["app", "list", "--config", str(tmp_path / "config.yaml"), "--workspace-mode", "portable"]
|
||||
|
||||
|
||||
def test_list_is_accepted_and_documented() -> None:
|
||||
"""Expose the list command in argument parsing and both supported help languages."""
|
||||
assert "list" in VALID_COMMANDS
|
||||
assert parse_args(["app", "list"]).command == "list"
|
||||
assert "list -" in help_text(language = "en")
|
||||
assert "list -" in help_text(language = "de")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("initial_language", ["en", "de"])
|
||||
@pytest.mark.parametrize(("language", "description"), [
|
||||
("en", "lists current online ads with ID, title, state, and expiry date"),
|
||||
("de", "Zeigt aktuelle Online-Anzeigen mit ID, Titel, Status und Ablaufdatum an"),
|
||||
])
|
||||
def test_list_help_honors_explicit_language_and_preserves_locale(initial_language:str, language:str, description:str) -> None:
|
||||
"""Honor the requested help language without changing the caller locale."""
|
||||
locale = i18n.Locale(initial_language, "TEST")
|
||||
i18n.set_current_locale(locale)
|
||||
|
||||
assert f"list - {description}" in help_text(language = language)
|
||||
assert i18n.get_current_locale() == locale
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_shows_every_online_page_without_local_or_account_mutations(tmp_path:Path, capsys:pytest.CaptureFixture[str]) -> None:
|
||||
"""List all account pages while preserving local files and fetched account data."""
|
||||
(tmp_path / "config.yaml").write_text("configuration sentinel\n", encoding = "utf-8")
|
||||
(tmp_path / "car.yaml").write_text("id: 999\ntitle: Local car sentinel\n", encoding = "utf-8")
|
||||
(tmp_path / "car.jpg").write_bytes(b"image sentinel")
|
||||
files_before = _snapshot_files(tmp_path)
|
||||
ads:list[published_ads.PublishedAd] = [
|
||||
{"id": 123, "title": "Audi A3", "state": "active", "endDate": "05.10.2026"},
|
||||
{"id": "456", "title": "VW Golf", "state": "paused", "endDate": "06.10.2026"},
|
||||
{"id": 789, "title": "Opel Corsa", "state": "inactive", "endDate": "07.10.2026"},
|
||||
]
|
||||
responses:list[PageResponse] = [_response(ads[:2], last = 2), _response(ads[2:], page = 2, last = 2)]
|
||||
responses_before = copy.deepcopy(responses)
|
||||
bot = FakeListBot(responses)
|
||||
|
||||
await bot.run(_list_args(tmp_path))
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert output == (
|
||||
"ID: 123\n title: Audi A3\n status: active\n expires: 05.10.2026\n\n"
|
||||
"ID: 456\n title: VW Golf\n status: reserved\n expires: 06.10.2026\n\n"
|
||||
"ID: 789\n title: Opel Corsa\n status: inactive\n expires: 07.10.2026\n"
|
||||
)
|
||||
assert len(bot.requests) == 2
|
||||
assert bot.bootstrapped
|
||||
assert bot.closed
|
||||
assert not bot.logged_in
|
||||
assert responses == responses_before
|
||||
assert _snapshot_files(tmp_path) == files_before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_reports_empty_account_and_closes_browser(tmp_path:Path, capsys:pytest.CaptureFixture[str]) -> None:
|
||||
"""Report an empty account and clean up the simulated browser session."""
|
||||
bot = FakeListBot([_response([])])
|
||||
|
||||
await bot.run(_list_args(tmp_path))
|
||||
|
||||
assert capsys.readouterr().out == "No online ads found.\n"
|
||||
assert len(bot.requests) == 1
|
||||
assert bot.closed
|
||||
assert not bot.logged_in
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("responses", [
|
||||
[_response([{"id": 123, "title": "Missing state"}])],
|
||||
[{"content": json.dumps({"ads": [{"id": 123, "state": "active"}]})}],
|
||||
[_response([{"id": 123, "state": "active", "title": "Incomplete car"}], last = 2), {"content": "invalid JSON"}],
|
||||
[_response([{"id": 123, "state": "active", "title": "Incomplete car"}], last = 2), TimeoutError("fake second page timeout")],
|
||||
])
|
||||
async def test_list_rejects_malformed_or_partial_results_and_closes_browser(
|
||||
responses:list[PageResponse], tmp_path:Path, capsys:pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Reject incomplete account responses without emitting a misleading partial list."""
|
||||
bot = FakeListBot(responses)
|
||||
|
||||
with pytest.raises(PublishedAdsFetchIncompleteError):
|
||||
await bot.run(_list_args(tmp_path))
|
||||
|
||||
assert not capsys.readouterr().out, "Partial account results must not look like a successful complete listing"
|
||||
assert bot.closed
|
||||
assert not bot.logged_in
|
||||
|
||||
|
||||
@pytest.mark.parametrize("optional_fields", [{}, {"title": None, "endDate": None}, {"title": "", "endDate": ""}])
|
||||
@pytest.mark.parametrize(("state", "state_label"), [("under_review", "under_review"), (None, "-")])
|
||||
def test_render_list_preserves_unknown_status_and_handles_missing_optional_fields(
|
||||
optional_fields:dict[str, Any], state:str | None, state_label:str,
|
||||
) -> None:
|
||||
"""Render missing fields and unknown statuses without modifying the supplied ad."""
|
||||
ad:published_ads.PublishedAd = {"id": 123, "state": state, **optional_fields}
|
||||
ad_before = copy.deepcopy(ad)
|
||||
|
||||
assert published_ads.render_published_ads([ad]) == f"ID: 123\n title: -\n status: {state_label}\n expires: -"
|
||||
assert ad == ad_before
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("language", "expected", "empty_expected"), [
|
||||
("en", "ID: 123\n title: Audi A3\n status: reserved\n expires: 05.10.2026", "No online ads found."),
|
||||
("de", "ID: 123\n Titel: Audi A3\n Status: reserviert\n Ablaufdatum: 05.10.2026", "Keine Online-Anzeigen gefunden."),
|
||||
])
|
||||
def test_render_list_translates_labels_status_and_empty_account(language:str, expected:str, empty_expected:str) -> None:
|
||||
"""Translate account-list labels, reservation status, and the empty-account message."""
|
||||
i18n.set_current_locale(i18n.Locale(language))
|
||||
ads:list[published_ads.PublishedAd] = [{"id": 123, "title": "Audi A3", "state": "paused", "endDate": "05.10.2026"}]
|
||||
|
||||
assert published_ads.render_published_ads(ads) == expected
|
||||
assert published_ads.render_published_ads([]) == empty_expected
|
||||
Reference in new issue
Block a user