Skip to content

Commit 147610b

Browse files
Add tests, CI, and py.typed marker
29 tests covering API resolvers, HTML extraction, slim output, and all CLI commands. GitHub Actions runs pytest on Python 3.10–3.13.
1 parent 8645904 commit 147610b

File tree

7 files changed

+315
-0
lines changed

7 files changed

+315
-0
lines changed

.github/workflows/ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
python-version: ["3.10", "3.11", "3.12", "3.13"]
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-python@v5
18+
with:
19+
python-version: ${{ matrix.python-version }}
20+
- run: pip install -e ".[dev]"
21+
- run: pytest -v

blocket_cli/py.typed

Whitespace-only changes.

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ dependencies = [
2323
[project.scripts]
2424
blocket = "blocket_cli.cli:main"
2525

26+
[project.optional-dependencies]
27+
dev = ["pytest>=8.0"]
28+
29+
[tool.pytest.ini_options]
30+
testpaths = ["tests"]
31+
2632
[build-system]
2733
requires = ["hatchling"]
2834
build-backend = "hatchling.build"

tests/__init__.py

Whitespace-only changes.

tests/test_api.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Tests for the API client (no network calls)."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
7+
import pytest
8+
9+
from blocket_cli.api import (
10+
CATEGORIES,
11+
LOCATIONS,
12+
_extract_product_data,
13+
_resolve_category,
14+
_resolve_location,
15+
)
16+
17+
18+
class TestResolveLocation:
19+
def test_valid(self):
20+
assert _resolve_location("stockholm") == "0.300001"
21+
22+
def test_case_insensitive(self):
23+
assert _resolve_location("Stockholm") == "0.300001"
24+
25+
def test_invalid(self):
26+
with pytest.raises(ValueError, match="Unknown location 'narnia'"):
27+
_resolve_location("narnia")
28+
29+
def test_all_locations_have_codes(self):
30+
for name, code in LOCATIONS.items():
31+
assert code.startswith("0.3000"), f"{name} has unexpected code {code}"
32+
33+
34+
class TestResolveCategory:
35+
def test_valid(self):
36+
assert _resolve_category("electronics") == "0.93"
37+
38+
def test_case_insensitive(self):
39+
assert _resolve_category("Electronics") == "0.93"
40+
41+
def test_invalid(self):
42+
with pytest.raises(ValueError, match="Unknown category 'weapons'"):
43+
_resolve_category("weapons")
44+
45+
def test_all_categories_have_codes(self):
46+
for name, code in CATEGORIES.items():
47+
assert code.startswith("0."), f"{name} has unexpected code {code}"
48+
49+
50+
class TestExtractProductData:
51+
def test_extracts_product(self):
52+
product = {"@type": "Product", "name": "Soffa", "offers": {"price": "500"}}
53+
html = f'<html><head><script type="application/ld+json">{json.dumps(product)}</script></head></html>'
54+
assert _extract_product_data(html) == product
55+
56+
def test_ignores_non_product(self):
57+
other = {"@type": "Organization", "name": "Blocket"}
58+
html = f'<html><script type="application/ld+json">{json.dumps(other)}</script></html>'
59+
assert _extract_product_data(html) is None
60+
61+
def test_handles_multiple_scripts(self):
62+
org = {"@type": "Organization", "name": "Blocket"}
63+
product = {"@type": "Product", "name": "Cykel"}
64+
html = (
65+
f'<script type="application/ld+json">{json.dumps(org)}</script>'
66+
f'<script type="application/ld+json">{json.dumps(product)}</script>'
67+
)
68+
assert _extract_product_data(html) == product
69+
70+
def test_handles_invalid_json(self):
71+
html = '<script type="application/ld+json">not json</script>'
72+
assert _extract_product_data(html) is None
73+
74+
def test_handles_no_scripts(self):
75+
assert _extract_product_data("<html><body>Hello</body></html>") is None

tests/test_cli.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Tests for CLI commands (mocked HTTP)."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from unittest.mock import patch
7+
8+
from click.testing import CliRunner
9+
10+
from blocket_cli.cli import main
11+
12+
SEARCH_RESPONSE = {
13+
"docs": [
14+
{
15+
"id": "1",
16+
"heading": "Test soffa",
17+
"price": {"amount": 2000, "currency_code": "SEK", "price_unit": "kr"},
18+
"location": "Stockholm",
19+
"canonical_url": "https://www.blocket.se/item/1",
20+
"timestamp": 1700000000000,
21+
"extras": [],
22+
"image_urls": ["https://img.com/1.jpg"],
23+
"coordinates": {"lat": 59.0, "lon": 18.0},
24+
}
25+
],
26+
"metadata": {"result_size": {"match_count": 1}},
27+
}
28+
29+
30+
def _mock_search(*args, **kwargs):
31+
return SEARCH_RESPONSE
32+
33+
34+
def _mock_get_ad(*args, **kwargs):
35+
return {
36+
"@type": "Product",
37+
"name": "Test soffa",
38+
"description": "En fin soffa",
39+
"offers": {"price": "2000", "priceCurrency": "SEK"},
40+
"url": "https://www.blocket.se/item/1",
41+
}
42+
43+
44+
class TestSearchCommand:
45+
def test_table_output(self):
46+
with patch("blocket_cli.cli.api.search", _mock_search):
47+
result = CliRunner().invoke(main, ["search", "soffa"])
48+
assert result.exit_code == 0
49+
assert "Test soffa" in result.output
50+
assert "2 000 kr" in result.output
51+
52+
def test_json_output_is_slim(self):
53+
with patch("blocket_cli.cli.api.search", _mock_search):
54+
result = CliRunner().invoke(main, ["search", "soffa", "-o", "json"])
55+
data = json.loads(result.output)
56+
listing = data["results"][0]
57+
assert listing["id"] == "1"
58+
assert listing["price"] == 2000
59+
assert "image_urls" not in listing
60+
assert "coordinates" not in listing
61+
62+
def test_json_raw_output(self):
63+
with patch("blocket_cli.cli.api.search", _mock_search):
64+
result = CliRunner().invoke(main, ["search", "soffa", "-o", "json", "--raw"])
65+
data = json.loads(result.output)
66+
listing = data["results"][0]
67+
assert "image_urls" in listing
68+
assert "coordinates" in listing
69+
70+
def test_limit(self):
71+
with patch("blocket_cli.cli.api.search", _mock_search):
72+
result = CliRunner().invoke(main, ["search", "soffa", "-o", "json", "-n", "1"])
73+
data = json.loads(result.output)
74+
assert len(data["results"]) == 1
75+
76+
def test_invalid_category(self):
77+
result = CliRunner().invoke(main, ["search", "x", "-c", "badcat"])
78+
assert result.exit_code == 1
79+
assert "Unknown category" in result.output
80+
81+
def test_invalid_location(self):
82+
result = CliRunner().invoke(main, ["search", "x", "-l", "narnia"])
83+
assert result.exit_code == 1
84+
assert "Unknown location" in result.output
85+
86+
87+
class TestAdCommand:
88+
def test_table_output(self):
89+
with patch("blocket_cli.cli.api.get_ad", _mock_get_ad):
90+
result = CliRunner().invoke(main, ["ad", "1"])
91+
assert result.exit_code == 0
92+
assert "Test soffa" in result.output
93+
assert "2000 SEK" in result.output
94+
95+
def test_json_output(self):
96+
with patch("blocket_cli.cli.api.get_ad", _mock_get_ad):
97+
result = CliRunner().invoke(main, ["ad", "1", "-o", "json"])
98+
data = json.loads(result.output)
99+
assert data["name"] == "Test soffa"
100+
101+
102+
class TestListCommands:
103+
def test_categories(self):
104+
result = CliRunner().invoke(main, ["categories"])
105+
assert result.exit_code == 0
106+
assert "electronics" in result.output
107+
108+
def test_locations(self):
109+
result = CliRunner().invoke(main, ["locations"])
110+
assert result.exit_code == 0
111+
assert "stockholm" in result.output

tests/test_format.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Tests for output formatting."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
7+
from blocket_cli.format import _slim
8+
9+
10+
class TestSlim:
11+
def test_essential_fields(self):
12+
doc = {
13+
"id": "123",
14+
"heading": "Test item",
15+
"price": {"amount": 500, "currency_code": "SEK", "price_unit": "kr"},
16+
"location": "Stockholm",
17+
"canonical_url": "https://www.blocket.se/item/123",
18+
"timestamp": 1700000000000,
19+
}
20+
result = _slim(doc)
21+
assert result == {
22+
"id": "123",
23+
"heading": "Test item",
24+
"price": 500,
25+
"currency": "SEK",
26+
"location": "Stockholm",
27+
"url": "https://www.blocket.se/item/123",
28+
"date": "2023-11-14",
29+
}
30+
31+
def test_strips_images_and_coordinates(self):
32+
doc = {
33+
"id": "1",
34+
"heading": "X",
35+
"price": {"amount": 100, "currency_code": "SEK"},
36+
"location": "A",
37+
"canonical_url": "https://example.com",
38+
"image": {"url": "https://img.com/1.jpg", "width": 800, "height": 600},
39+
"image_urls": ["https://img.com/1.jpg", "https://img.com/2.jpg"],
40+
"coordinates": {"lat": 59.0, "lon": 18.0},
41+
"flags": ["private"],
42+
"labels": [{"id": "private", "text": "Privat"}],
43+
"ad_type": 67,
44+
"distance": 0.0,
45+
"main_search_key": "SEARCH_ID_BAP_ALL",
46+
}
47+
result = _slim(doc)
48+
assert "image" not in result
49+
assert "image_urls" not in result
50+
assert "coordinates" not in result
51+
assert "flags" not in result
52+
assert "labels" not in result
53+
assert "ad_type" not in result
54+
assert "distance" not in result
55+
assert "main_search_key" not in result
56+
57+
def test_extracts_brand(self):
58+
doc = {
59+
"id": "1",
60+
"heading": "Phone",
61+
"price": {"amount": 3000, "currency_code": "SEK"},
62+
"location": "Malmö",
63+
"canonical_url": "https://example.com",
64+
"extras": [{"id": "brand", "values": ["Apple"]}],
65+
}
66+
assert _slim(doc)["brand"] == "Apple"
67+
68+
def test_no_brand_when_missing(self):
69+
doc = {
70+
"id": "1",
71+
"heading": "Thing",
72+
"price": {"amount": 100, "currency_code": "SEK"},
73+
"location": "A",
74+
"canonical_url": "https://example.com",
75+
"extras": [],
76+
}
77+
assert "brand" not in _slim(doc)
78+
79+
def test_no_timestamp(self):
80+
doc = {
81+
"id": "1",
82+
"heading": "X",
83+
"price": {},
84+
"location": "A",
85+
"canonical_url": "https://example.com",
86+
}
87+
result = _slim(doc)
88+
assert "date" not in result
89+
90+
def test_output_is_json_serializable(self):
91+
doc = {
92+
"id": "1",
93+
"heading": "Ö-ring",
94+
"price": {"amount": 50, "currency_code": "SEK"},
95+
"location": "Göteborg",
96+
"canonical_url": "https://example.com",
97+
"timestamp": 1700000000000,
98+
"extras": [{"id": "brand", "values": ["IKEA"]}],
99+
}
100+
dumped = json.dumps(_slim(doc), ensure_ascii=False)
101+
assert "Ö-ring" in dumped
102+
assert "Göteborg" in dumped

0 commit comments

Comments
 (0)