cli-web-amazon 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli_web/amazon/README.md +83 -0
- cli_web/amazon/__init__.py +3 -0
- cli_web/amazon/__main__.py +6 -0
- cli_web/amazon/amazon_cli.py +155 -0
- cli_web/amazon/commands/__init__.py +1 -0
- cli_web/amazon/commands/bestsellers.py +61 -0
- cli_web/amazon/commands/product.py +36 -0
- cli_web/amazon/commands/search.py +45 -0
- cli_web/amazon/commands/suggest.py +36 -0
- cli_web/amazon/core/__init__.py +1 -0
- cli_web/amazon/core/client.py +416 -0
- cli_web/amazon/core/exceptions.py +76 -0
- cli_web/amazon/core/models.py +63 -0
- cli_web/amazon/skills/SKILL.md +105 -0
- cli_web/amazon/tests/TEST.md +173 -0
- cli_web/amazon/tests/__init__.py +1 -0
- cli_web/amazon/tests/test_core.py +369 -0
- cli_web/amazon/tests/test_e2e.py +355 -0
- cli_web/amazon/utils/__init__.py +1 -0
- cli_web/amazon/utils/config.py +5 -0
- cli_web/amazon/utils/doctor.py +188 -0
- cli_web/amazon/utils/helpers.py +127 -0
- cli_web/amazon/utils/mcp_server.py +290 -0
- cli_web/amazon/utils/output.py +130 -0
- cli_web/amazon/utils/repl_skin.py +486 -0
- cli_web_amazon-0.1.1.dist-info/METADATA +14 -0
- cli_web_amazon-0.1.1.dist-info/RECORD +30 -0
- cli_web_amazon-0.1.1.dist-info/WHEEL +5 -0
- cli_web_amazon-0.1.1.dist-info/entry_points.txt +2 -0
- cli_web_amazon-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# TEST.md — cli-web-amazon
|
|
2
|
+
|
|
3
|
+
## Part 1: Test Plan
|
|
4
|
+
|
|
5
|
+
### Test Inventory
|
|
6
|
+
|
|
7
|
+
| File | Tests | Purpose |
|
|
8
|
+
|------|-------|---------|
|
|
9
|
+
| `test_core.py` | 27 | Unit tests — mocked HTTP, no network |
|
|
10
|
+
| `test_e2e.py` | ~30 | Live E2E + subprocess tests |
|
|
11
|
+
| **Total** | **~57** | |
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
### Unit Test Plan (`test_core.py`)
|
|
16
|
+
|
|
17
|
+
#### `TestExceptions` (4 tests)
|
|
18
|
+
- `RateLimitError` stores `retry_after` float
|
|
19
|
+
- `ServerError` stores `status_code` int
|
|
20
|
+
- `error_code_for()` maps exception types → string codes: `RATE_LIMITED`, `NOT_FOUND`, `SERVER_ERROR`, `UNKNOWN_ERROR`
|
|
21
|
+
|
|
22
|
+
#### `TestModels` (4 tests)
|
|
23
|
+
- `SearchResult.to_dict()` — all fields round-trip through dict
|
|
24
|
+
- `Product.to_dict()` — ASIN + brand preserved
|
|
25
|
+
- `BestSeller.to_dict()` — rank + ASIN preserved
|
|
26
|
+
- `Suggestion.to_dict()` — value + type preserved
|
|
27
|
+
|
|
28
|
+
#### `TestClientSuggestions` (3 tests)
|
|
29
|
+
- Mocked JSON response → `get_suggestions()` returns `Suggestion` list
|
|
30
|
+
- Empty suggestions list returns `[]`
|
|
31
|
+
- 429 response raises `RateLimitError` with correct `retry_after`
|
|
32
|
+
|
|
33
|
+
#### `TestClientSearch` (3 tests)
|
|
34
|
+
- Mocked HTML with real `data-component-type="s-search-result"` + `data-asin` attrs → returns `SearchResult` list with correct ASINs and titles
|
|
35
|
+
- Empty HTML page → returns `[]`
|
|
36
|
+
- URL normalization: relative `/dp/...` paths become `https://www.amazon.com/dp/...`
|
|
37
|
+
|
|
38
|
+
#### `TestClientProductDetail` (2 tests)
|
|
39
|
+
- Mocked HTML with real Amazon product page structure (`#productTitle`, `.a-offscreen`, `#acrPopover`, `#bylineInfo`, `#landingImage`) → all fields parsed correctly
|
|
40
|
+
- Product URL reflects canonical `https://www.amazon.com/dp/<ASIN>` form
|
|
41
|
+
|
|
42
|
+
#### `TestClientBestSellers` (2 tests)
|
|
43
|
+
- Mocked HTML with `#gridItemRoot` + `.zg-bdg-text` rank badges → `BestSeller` list with correct rank, ASIN, title, price
|
|
44
|
+
- Ranks returned in sequential order (1, 2, ...)
|
|
45
|
+
|
|
46
|
+
#### `TestHelpers` (7 tests)
|
|
47
|
+
- `sanitize_filename()` passes through safe names unchanged
|
|
48
|
+
- Strips `/` and `:` from filenames
|
|
49
|
+
- Empty/whitespace string → `"untitled"`
|
|
50
|
+
- `handle_errors(json_mode=False)` on `NotFoundError` → `SystemExit(1)`
|
|
51
|
+
- `handle_errors(json_mode=True)` on `NotFoundError` → JSON output `{"error": true, "code": "NOT_FOUND", ...}`
|
|
52
|
+
- `handle_errors()` on unknown exception → `SystemExit(2)`
|
|
53
|
+
- `handle_errors()` on `KeyboardInterrupt` → `SystemExit(130)`
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
### E2E Test Plan (`test_e2e.py`)
|
|
58
|
+
|
|
59
|
+
**Site profile:** Amazon.com — No-auth, read-only. All commands use public endpoints.
|
|
60
|
+
|
|
61
|
+
#### `TestE2ESuggest` (4 tests) — live autocomplete API
|
|
62
|
+
- `suggest "laptop"` returns non-empty list with `value` and `type` fields
|
|
63
|
+
- Response includes at least one `KEYWORD` type entry
|
|
64
|
+
- No raw protocol data leakage in suggestion values
|
|
65
|
+
- Unusual query returns empty list without raising
|
|
66
|
+
|
|
67
|
+
#### `TestE2ESearch` (5 tests) — live HTML search
|
|
68
|
+
- `search "laptop"` returns results with 10-char ASINs
|
|
69
|
+
- Each result has non-empty title and URL starting with `https://www.amazon.com`
|
|
70
|
+
- URL contains the ASIN of the result
|
|
71
|
+
- Page 1 and page 2 return different ASINs (pagination works)
|
|
72
|
+
- No raw protocol data in titles
|
|
73
|
+
|
|
74
|
+
#### `TestE2EProduct` (5 tests) — live product detail page
|
|
75
|
+
- `get_product("B0GRZ78683")` returns product with correct ASIN and non-trivial title
|
|
76
|
+
- Product URL contains ASIN
|
|
77
|
+
- Rating field (if present) contains "out of 5" format
|
|
78
|
+
- Round-trip: `search` → pick first ASIN → `get_product` → verify ASIN matches
|
|
79
|
+
- No raw protocol data in title or price
|
|
80
|
+
|
|
81
|
+
#### `TestE2EBestSellers` (5 tests) — live bestseller page
|
|
82
|
+
- `get_bestsellers("electronics")` returns non-empty list with rank=1 first
|
|
83
|
+
- All ASINs are exactly 10 characters
|
|
84
|
+
- Ranks are sequential and start at 1
|
|
85
|
+
- All titles are non-empty
|
|
86
|
+
- Each item's URL contains its ASIN
|
|
87
|
+
|
|
88
|
+
#### `TestCLISubprocess` (≥11 tests) — installed `cli-web-amazon` binary
|
|
89
|
+
- `--help` loads successfully
|
|
90
|
+
- `--version` works
|
|
91
|
+
- `search laptop --json` → valid JSON array with `asin`, `title` fields; ASINs are 10 chars
|
|
92
|
+
- `search laptop --json` → no RPC leakage in titles
|
|
93
|
+
- `suggest laptop --json` → valid JSON array with `value` and `type` fields
|
|
94
|
+
- `product get B0GRZ78683 --json` → JSON object with correct `asin`, non-empty `title`, `url` containing ASIN
|
|
95
|
+
- `product get B0GRZ78683 --json` → no RPC data in title
|
|
96
|
+
- `bestsellers electronics --json` → JSON array; first item `rank == 1`
|
|
97
|
+
- `bestsellers electronics --json` → all items have `asin`, `title`, `rank`
|
|
98
|
+
- `search laptop --page 2 --json` → valid JSON array (pagination option works)
|
|
99
|
+
- `search laptop --dept electronics --json` → valid JSON array (department filter works)
|
|
100
|
+
- `product get BADASIN000 --json` → structured error JSON on failure (no crash)
|
|
101
|
+
- `search --help`, `product --help`, `bestsellers --help`, `suggest --help` all return exit 0
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
### Realistic Workflow Scenarios
|
|
106
|
+
|
|
107
|
+
**Scenario 1 — Product discovery pipeline:**
|
|
108
|
+
```
|
|
109
|
+
suggest "wireless headphones" --json
|
|
110
|
+
→ pick top KEYWORD suggestion
|
|
111
|
+
→ search "<suggestion>" --json
|
|
112
|
+
→ pick top ASIN
|
|
113
|
+
→ product get <ASIN> --json
|
|
114
|
+
→ verify product fields match listing
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Scenario 2 — Category exploration:**
|
|
118
|
+
```
|
|
119
|
+
bestsellers electronics --json
|
|
120
|
+
→ extract top 5 ASINs
|
|
121
|
+
→ product get <ASIN> --json for each
|
|
122
|
+
→ compare prices and ratings
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
### Known Gaps
|
|
128
|
+
|
|
129
|
+
- `price` and `review_count` are empty in search results — Amazon client-side renders these fields; `product get` returns reliable pricing
|
|
130
|
+
- `product get` may return empty `price` for geo-restricted products; `price_note` explains the reason
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Part 2: Test Results
|
|
135
|
+
|
|
136
|
+
### Run Date: 2026-04-05
|
|
137
|
+
|
|
138
|
+
### Full `pytest -v --tb=no` Output
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
============================= test session starts =============================
|
|
142
|
+
platform win32 -- Python 3.12, pytest-9.x
|
|
143
|
+
|
|
144
|
+
cli_web/amazon/tests/test_core.py::TestExceptions::test_rate_limit_error_with_retry_after PASSED
|
|
145
|
+
cli_web/amazon/tests/test_core.py::TestExceptions::test_server_error_status_code PASSED
|
|
146
|
+
cli_web/amazon/tests/test_core.py::TestExceptions::test_error_code_for_rate_limit PASSED
|
|
147
|
+
cli_web/amazon/tests/test_core.py::TestExceptions::test_error_code_for_not_found PASSED
|
|
148
|
+
cli_web/amazon/tests/test_core.py::TestExceptions::test_error_code_for_server_error PASSED
|
|
149
|
+
cli_web/amazon/tests/test_core.py::TestExceptions::test_error_code_for_unknown PASSED
|
|
150
|
+
cli_web/amazon/tests/test_core.py::TestModels::test_bestseller_to_dict PASSED
|
|
151
|
+
cli_web/amazon/tests/test_core.py::TestModels::test_product_to_dict PASSED
|
|
152
|
+
cli_web/amazon/tests/test_core.py::TestModels::test_search_result_to_dict PASSED
|
|
153
|
+
cli_web/amazon/tests/test_core.py::TestModels::test_suggestion_to_dict PASSED
|
|
154
|
+
cli_web/amazon/tests/test_core.py::TestClientSuggestions::test_get_suggestions_429_raises_rate_limit PASSED
|
|
155
|
+
cli_web/amazon/tests/test_core.py::TestClientSuggestions::test_get_suggestions_empty_results PASSED
|
|
156
|
+
cli_web/amazon/tests/test_core.py::TestClientSuggestions::test_get_suggestions_parses_keywords PASSED
|
|
157
|
+
cli_web/amazon/tests/test_core.py::TestClientSearch::test_search_empty_page PASSED
|
|
158
|
+
cli_web/amazon/tests/test_core.py::TestClientSearch::test_search_returns_products PASSED
|
|
159
|
+
cli_web/amazon/tests/test_core.py::TestClientSearch::test_search_url_normalization PASSED
|
|
160
|
+
cli_web/amazon/tests/test_core.py::TestClientProductDetail::test_get_product_parses_all_fields PASSED
|
|
161
|
+
cli_web/amazon/tests/test_core.py::TestClientProductDetail::test_get_product_url PASSED
|
|
162
|
+
cli_web/amazon/tests/test_core.py::TestClientBestSellers::test_get_bestsellers_parses_items PASSED
|
|
163
|
+
cli_web/amazon/tests/test_core.py::TestClientBestSellers::test_get_bestsellers_rank_order PASSED
|
|
164
|
+
cli_web/amazon/tests/test_core.py::TestHelpers::test_handle_errors_json_mode_outputs_json PASSED
|
|
165
|
+
cli_web/amazon/tests/test_core.py::TestHelpers::test_handle_errors_keyboard_interrupt_exits_130 PASSED
|
|
166
|
+
cli_web/amazon/tests/test_core.py::TestHelpers::test_handle_errors_not_found_exits_1 PASSED
|
|
167
|
+
cli_web/amazon/tests/test_core.py::TestHelpers::test_handle_errors_unknown_exits_2 PASSED
|
|
168
|
+
cli_web/amazon/tests/test_core.py::TestHelpers::test_sanitize_filename_basic PASSED
|
|
169
|
+
cli_web/amazon/tests/test_core.py::TestHelpers::test_sanitize_filename_empty PASSED
|
|
170
|
+
cli_web/amazon/tests/test_core.py::TestHelpers::test_sanitize_filename_invalid_chars PASSED
|
|
171
|
+
|
|
172
|
+
============================== 27 passed in 4.3s ==============================
|
|
173
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tests for cli-web-amazon."""
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Unit tests for cli-web-amazon core modules (mocked HTTP)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import unittest
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
from cli_web.amazon.core.exceptions import (
|
|
8
|
+
NotFoundError,
|
|
9
|
+
RateLimitError,
|
|
10
|
+
ServerError,
|
|
11
|
+
error_code_for,
|
|
12
|
+
)
|
|
13
|
+
from cli_web.amazon.core.models import (
|
|
14
|
+
BestSeller,
|
|
15
|
+
Product,
|
|
16
|
+
SearchResult,
|
|
17
|
+
Suggestion,
|
|
18
|
+
)
|
|
19
|
+
from cli_web.amazon.utils.helpers import handle_errors, sanitize_filename
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Exception hierarchy tests
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestExceptions(unittest.TestCase):
|
|
27
|
+
def test_rate_limit_error_with_retry_after(self):
|
|
28
|
+
exc = RateLimitError("too many requests", retry_after=60.0)
|
|
29
|
+
self.assertEqual(exc.retry_after, 60.0)
|
|
30
|
+
|
|
31
|
+
def test_server_error_status_code(self):
|
|
32
|
+
exc = ServerError("internal error", status_code=503)
|
|
33
|
+
self.assertEqual(exc.status_code, 503)
|
|
34
|
+
|
|
35
|
+
def test_error_code_for_rate_limit(self):
|
|
36
|
+
self.assertEqual(error_code_for(RateLimitError("x")), "RATE_LIMITED")
|
|
37
|
+
|
|
38
|
+
def test_error_code_for_not_found(self):
|
|
39
|
+
self.assertEqual(error_code_for(NotFoundError("x")), "NOT_FOUND")
|
|
40
|
+
|
|
41
|
+
def test_error_code_for_server_error(self):
|
|
42
|
+
self.assertEqual(error_code_for(ServerError("x")), "SERVER_ERROR")
|
|
43
|
+
|
|
44
|
+
def test_error_code_for_unknown(self):
|
|
45
|
+
self.assertEqual(error_code_for(ValueError("x")), "UNKNOWN_ERROR")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Model tests
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestModels(unittest.TestCase):
|
|
54
|
+
def test_search_result_to_dict(self):
|
|
55
|
+
r = SearchResult(
|
|
56
|
+
asin="B0GRZ78683",
|
|
57
|
+
title="Dell Laptop",
|
|
58
|
+
price="$799",
|
|
59
|
+
rating="4.8 out of 5 stars",
|
|
60
|
+
review_count="(14)",
|
|
61
|
+
url="https://www.amazon.com/dp/B0GRZ78683",
|
|
62
|
+
)
|
|
63
|
+
d = r.to_dict()
|
|
64
|
+
self.assertEqual(d["asin"], "B0GRZ78683")
|
|
65
|
+
self.assertEqual(d["title"], "Dell Laptop")
|
|
66
|
+
self.assertIn("price", d)
|
|
67
|
+
self.assertIn("rating", d)
|
|
68
|
+
|
|
69
|
+
def test_product_to_dict(self):
|
|
70
|
+
p = Product(
|
|
71
|
+
asin="B0GRZ78683",
|
|
72
|
+
title="Dell Laptop",
|
|
73
|
+
price="$799",
|
|
74
|
+
brand="Dell",
|
|
75
|
+
)
|
|
76
|
+
d = p.to_dict()
|
|
77
|
+
self.assertEqual(d["asin"], "B0GRZ78683")
|
|
78
|
+
self.assertEqual(d["brand"], "Dell")
|
|
79
|
+
|
|
80
|
+
def test_bestseller_to_dict(self):
|
|
81
|
+
b = BestSeller(rank=1, asin="B08JHCVHTY", title="Blink Camera", price="$34.99")
|
|
82
|
+
d = b.to_dict()
|
|
83
|
+
self.assertEqual(d["rank"], 1)
|
|
84
|
+
self.assertEqual(d["asin"], "B08JHCVHTY")
|
|
85
|
+
|
|
86
|
+
def test_suggestion_to_dict(self):
|
|
87
|
+
s = Suggestion(value="laptop stand", type="KEYWORD")
|
|
88
|
+
d = s.to_dict()
|
|
89
|
+
self.assertEqual(d["value"], "laptop stand")
|
|
90
|
+
self.assertEqual(d["type"], "KEYWORD")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Client tests (mocked)
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TestClientSuggestions(unittest.TestCase):
|
|
99
|
+
"""Test suggestions API with mocked httpx."""
|
|
100
|
+
|
|
101
|
+
def _mock_response(self, json_data: dict, status: int = 200):
|
|
102
|
+
resp = MagicMock()
|
|
103
|
+
resp.status_code = status
|
|
104
|
+
resp.json.return_value = json_data
|
|
105
|
+
resp.text = json.dumps(json_data)
|
|
106
|
+
return resp
|
|
107
|
+
|
|
108
|
+
def test_get_suggestions_parses_keywords(self):
|
|
109
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
110
|
+
|
|
111
|
+
suggestions_json = {
|
|
112
|
+
"suggestions": [
|
|
113
|
+
{"value": "laptop", "type": "KEYWORD"},
|
|
114
|
+
{"value": "laptop stand", "type": "KEYWORD"},
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
with AmazonClient() as client:
|
|
118
|
+
with patch.object(
|
|
119
|
+
client._client, "get", return_value=self._mock_response(suggestions_json)
|
|
120
|
+
):
|
|
121
|
+
results = client.get_suggestions("laptop")
|
|
122
|
+
|
|
123
|
+
self.assertEqual(len(results), 2)
|
|
124
|
+
self.assertEqual(results[0].value, "laptop")
|
|
125
|
+
self.assertEqual(results[1].value, "laptop stand")
|
|
126
|
+
|
|
127
|
+
def test_get_suggestions_empty_results(self):
|
|
128
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
129
|
+
|
|
130
|
+
with AmazonClient() as client:
|
|
131
|
+
with patch.object(
|
|
132
|
+
client._client, "get", return_value=self._mock_response({"suggestions": []})
|
|
133
|
+
):
|
|
134
|
+
results = client.get_suggestions("zzzzzzz")
|
|
135
|
+
self.assertEqual(results, [])
|
|
136
|
+
|
|
137
|
+
def test_get_suggestions_429_raises_rate_limit(self):
|
|
138
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
139
|
+
|
|
140
|
+
resp = MagicMock()
|
|
141
|
+
resp.status_code = 429
|
|
142
|
+
resp.headers = {"retry-after": "30"}
|
|
143
|
+
with AmazonClient() as client:
|
|
144
|
+
with patch.object(client._client, "get", return_value=resp):
|
|
145
|
+
with self.assertRaises(RateLimitError) as ctx:
|
|
146
|
+
client.get_suggestions("test")
|
|
147
|
+
self.assertEqual(ctx.exception.retry_after, 30.0)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class TestClientSearch(unittest.TestCase):
|
|
151
|
+
"""Test search parsing with mocked HTML."""
|
|
152
|
+
|
|
153
|
+
SEARCH_HTML = """
|
|
154
|
+
<html><body>
|
|
155
|
+
<div data-component-type="s-search-result" data-asin="B0GRZ78683">
|
|
156
|
+
<h2>Dell Inspiron 15 Laptop</h2>
|
|
157
|
+
<span class="a-icon-alt">4.8 out of 5 stars</span>
|
|
158
|
+
<span aria-label="14 ratings">14 ratings</span>
|
|
159
|
+
<a class="a-link-normal" href="/dp/B0GRZ78683">View</a>
|
|
160
|
+
</div>
|
|
161
|
+
<div data-component-type="s-search-result" data-asin="B09R6FNNS1">
|
|
162
|
+
<h2>HP Laptop 15</h2>
|
|
163
|
+
<span class="a-icon-alt">4.3 out of 5 stars</span>
|
|
164
|
+
<a class="a-link-normal" href="/dp/B09R6FNNS1">View</a>
|
|
165
|
+
</div>
|
|
166
|
+
</body></html>
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def _mock_html_response(self, html: str):
|
|
170
|
+
resp = MagicMock()
|
|
171
|
+
resp.status_code = 200
|
|
172
|
+
resp.text = html
|
|
173
|
+
return resp
|
|
174
|
+
|
|
175
|
+
def test_search_returns_products(self):
|
|
176
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
177
|
+
|
|
178
|
+
with AmazonClient() as client:
|
|
179
|
+
with patch.object(
|
|
180
|
+
client._client, "get", return_value=self._mock_html_response(self.SEARCH_HTML)
|
|
181
|
+
):
|
|
182
|
+
results = client.search("laptop")
|
|
183
|
+
self.assertEqual(len(results), 2)
|
|
184
|
+
self.assertEqual(results[0].asin, "B0GRZ78683")
|
|
185
|
+
self.assertEqual(results[0].title, "Dell Inspiron 15 Laptop")
|
|
186
|
+
self.assertIn("4.8", results[0].rating)
|
|
187
|
+
|
|
188
|
+
def test_search_empty_page(self):
|
|
189
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
190
|
+
|
|
191
|
+
with AmazonClient() as client:
|
|
192
|
+
with patch.object(
|
|
193
|
+
client._client, "get", return_value=self._mock_html_response("<html></html>")
|
|
194
|
+
):
|
|
195
|
+
results = client.search("xyzabc123")
|
|
196
|
+
self.assertEqual(results, [])
|
|
197
|
+
|
|
198
|
+
def test_search_url_normalization(self):
|
|
199
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
200
|
+
|
|
201
|
+
with AmazonClient() as client:
|
|
202
|
+
with patch.object(
|
|
203
|
+
client._client, "get", return_value=self._mock_html_response(self.SEARCH_HTML)
|
|
204
|
+
):
|
|
205
|
+
results = client.search("laptop")
|
|
206
|
+
# URL should start with https://www.amazon.com
|
|
207
|
+
self.assertTrue(results[0].url.startswith("https://www.amazon.com"))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class TestClientProductDetail(unittest.TestCase):
|
|
211
|
+
"""Test product detail parsing."""
|
|
212
|
+
|
|
213
|
+
PRODUCT_HTML = """
|
|
214
|
+
<html><body>
|
|
215
|
+
<span id="productTitle">Dell Inspiron 15 Laptop Computer</span>
|
|
216
|
+
<span class="a-offscreen">$799.90</span>
|
|
217
|
+
<span id="acrPopover" title="4.8 out of 5 stars"></span>
|
|
218
|
+
<span id="acrCustomerReviewText">(14)</span>
|
|
219
|
+
<a id="bylineInfo">Visit the Dell Store</a>
|
|
220
|
+
<img id="landingImage" src="https://m.media-amazon.com/images/I/example.jpg">
|
|
221
|
+
</body></html>
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
def _mock_html_response(self, html: str, url: str = "https://www.amazon.com/dp/B0GRZ78683"):
|
|
225
|
+
resp = MagicMock()
|
|
226
|
+
resp.status_code = 200
|
|
227
|
+
resp.text = html
|
|
228
|
+
resp.url = url
|
|
229
|
+
return resp
|
|
230
|
+
|
|
231
|
+
def test_get_product_parses_all_fields(self):
|
|
232
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
233
|
+
|
|
234
|
+
with AmazonClient() as client:
|
|
235
|
+
with patch.object(
|
|
236
|
+
client._client, "get", return_value=self._mock_html_response(self.PRODUCT_HTML)
|
|
237
|
+
):
|
|
238
|
+
product = client.get_product("B0GRZ78683")
|
|
239
|
+
|
|
240
|
+
self.assertEqual(product.asin, "B0GRZ78683")
|
|
241
|
+
self.assertEqual(product.title, "Dell Inspiron 15 Laptop Computer")
|
|
242
|
+
self.assertEqual(product.price, "$799.90")
|
|
243
|
+
self.assertEqual(product.rating, "4.8 out of 5 stars")
|
|
244
|
+
self.assertEqual(product.review_count, "(14)")
|
|
245
|
+
self.assertEqual(product.brand, "Visit the Dell Store")
|
|
246
|
+
self.assertIn("m.media-amazon.com", product.image_url)
|
|
247
|
+
|
|
248
|
+
def test_get_product_url(self):
|
|
249
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
250
|
+
|
|
251
|
+
with AmazonClient() as client:
|
|
252
|
+
with patch.object(
|
|
253
|
+
client._client, "get", return_value=self._mock_html_response(self.PRODUCT_HTML)
|
|
254
|
+
):
|
|
255
|
+
product = client.get_product("B0GRZ78683")
|
|
256
|
+
self.assertEqual(product.url, "https://www.amazon.com/dp/B0GRZ78683")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class TestClientBestSellers(unittest.TestCase):
|
|
260
|
+
"""Test bestsellers parsing."""
|
|
261
|
+
|
|
262
|
+
BESTSELLERS_HTML = """
|
|
263
|
+
<html><body>
|
|
264
|
+
<div id="gridItemRoot">
|
|
265
|
+
<div data-asin="B08JHCVHTY">
|
|
266
|
+
<span class="zg-bdg-text">#1</span>
|
|
267
|
+
<img alt="Blink Outdoor Camera">
|
|
268
|
+
<a class="a-link-normal" href="/dp/B08JHCVHTY">View</a>
|
|
269
|
+
<span class="p13n-sc-price">$34.99</span>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
<div id="gridItemRoot">
|
|
273
|
+
<div data-asin="B0DCH8VDXF">
|
|
274
|
+
<span class="zg-bdg-text">#2</span>
|
|
275
|
+
<img alt="Apple EarPods">
|
|
276
|
+
<a class="a-link-normal" href="/dp/B0DCH8VDXF">View</a>
|
|
277
|
+
<span class="p13n-sc-price">$19.00</span>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
</body></html>
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
def _mock_html_response(self, html: str):
|
|
284
|
+
resp = MagicMock()
|
|
285
|
+
resp.status_code = 200
|
|
286
|
+
resp.text = html
|
|
287
|
+
return resp
|
|
288
|
+
|
|
289
|
+
def test_get_bestsellers_parses_items(self):
|
|
290
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
291
|
+
|
|
292
|
+
with AmazonClient() as client:
|
|
293
|
+
with patch.object(
|
|
294
|
+
client._client, "get", return_value=self._mock_html_response(self.BESTSELLERS_HTML)
|
|
295
|
+
):
|
|
296
|
+
items = client.get_bestsellers("electronics")
|
|
297
|
+
|
|
298
|
+
self.assertEqual(len(items), 2)
|
|
299
|
+
self.assertEqual(items[0].asin, "B08JHCVHTY")
|
|
300
|
+
self.assertEqual(items[0].rank, 1)
|
|
301
|
+
self.assertEqual(items[0].title, "Blink Outdoor Camera")
|
|
302
|
+
self.assertEqual(items[0].price, "$34.99")
|
|
303
|
+
|
|
304
|
+
def test_get_bestsellers_rank_order(self):
|
|
305
|
+
from cli_web.amazon.core.client import AmazonClient
|
|
306
|
+
|
|
307
|
+
with AmazonClient() as client:
|
|
308
|
+
with patch.object(
|
|
309
|
+
client._client, "get", return_value=self._mock_html_response(self.BESTSELLERS_HTML)
|
|
310
|
+
):
|
|
311
|
+
items = client.get_bestsellers("electronics")
|
|
312
|
+
self.assertEqual(items[0].rank, 1)
|
|
313
|
+
self.assertEqual(items[1].rank, 2)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
# Helpers tests
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class TestHelpers(unittest.TestCase):
|
|
322
|
+
def test_sanitize_filename_basic(self):
|
|
323
|
+
self.assertEqual(sanitize_filename("my product"), "my product")
|
|
324
|
+
|
|
325
|
+
def test_sanitize_filename_invalid_chars(self):
|
|
326
|
+
result = sanitize_filename("product/name:value")
|
|
327
|
+
self.assertNotIn("/", result)
|
|
328
|
+
self.assertNotIn(":", result)
|
|
329
|
+
|
|
330
|
+
def test_sanitize_filename_empty(self):
|
|
331
|
+
self.assertEqual(sanitize_filename(""), "untitled")
|
|
332
|
+
self.assertEqual(sanitize_filename(" "), "untitled")
|
|
333
|
+
|
|
334
|
+
def test_handle_errors_not_found_exits_1(self):
|
|
335
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
336
|
+
with handle_errors(json_mode=False):
|
|
337
|
+
raise NotFoundError("item not found")
|
|
338
|
+
self.assertEqual(ctx.exception.code, 1)
|
|
339
|
+
|
|
340
|
+
def test_handle_errors_json_mode_outputs_json(self):
|
|
341
|
+
from unittest.mock import patch as mock_patch
|
|
342
|
+
|
|
343
|
+
output = []
|
|
344
|
+
with mock_patch("click.echo", side_effect=output.append):
|
|
345
|
+
try:
|
|
346
|
+
with handle_errors(json_mode=True):
|
|
347
|
+
raise NotFoundError("item not found")
|
|
348
|
+
except SystemExit:
|
|
349
|
+
pass
|
|
350
|
+
self.assertEqual(len(output), 1)
|
|
351
|
+
data = json.loads(output[0])
|
|
352
|
+
self.assertTrue(data["error"])
|
|
353
|
+
self.assertEqual(data["code"], "NOT_FOUND")
|
|
354
|
+
|
|
355
|
+
def test_handle_errors_unknown_exits_2(self):
|
|
356
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
357
|
+
with handle_errors(json_mode=False):
|
|
358
|
+
raise ValueError("unexpected bug")
|
|
359
|
+
self.assertEqual(ctx.exception.code, 2)
|
|
360
|
+
|
|
361
|
+
def test_handle_errors_keyboard_interrupt_exits_130(self):
|
|
362
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
363
|
+
with handle_errors(json_mode=False):
|
|
364
|
+
raise KeyboardInterrupt()
|
|
365
|
+
self.assertEqual(ctx.exception.code, 130)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
if __name__ == "__main__":
|
|
369
|
+
unittest.main()
|