Delivers MIL-005 (lecture Step 4): the product URL comes from `PRODUCT_URL` (default: the lecture's Amazon page), price and title selectors and robot-check markers live in `constants.py`, and a robot-check page raises `RobotCheckError` with a clear message. The code does not try to solve or bypass the check. Outcome on the live page (2026-10-28): Amazon returned HTTP 200 with a robot-check page and no price; the page is kept (tokens redacted) as a test fixture and shown in the README. Two live requests were made in total, one to inspect the response and one for the screenshot; there are no retries or loops. Review: code against QC-PY-001 as RC-014 (Go, autonomous, not independent). 56 pytest tests (no network), ruff and mypy --strict pass. Task: MIL-005#1 Task: MIL-005#2 Task: MIL-005#3 Task: MIL-005#4 Task: MIL-005#5
105 lines
2.9 KiB
Python
105 lines
2.9 KiB
Python
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from amazon_price_tracker.domain.errors import (
|
|
PriceNotFoundError,
|
|
ProductParseError,
|
|
RobotCheckError,
|
|
TitleNotFoundError,
|
|
)
|
|
from amazon_price_tracker.domain.product import Product
|
|
from amazon_price_tracker.infrastructure.scraper import (
|
|
SoupProductParser,
|
|
get_price,
|
|
get_title,
|
|
is_robot_check,
|
|
parse_product,
|
|
)
|
|
|
|
FIXTURES = Path(__file__).resolve().parents[2] / "fixtures"
|
|
|
|
|
|
def _read(name: str) -> str:
|
|
return (FIXTURES / name).read_text(encoding="utf-8")
|
|
|
|
|
|
def test_get_price_returns_first_offscreen_as_float() -> None:
|
|
price = get_price(_read("practice_page.html"))
|
|
|
|
assert price == 99.99
|
|
assert isinstance(price, float)
|
|
|
|
|
|
def test_get_title_returns_stripped_text() -> None:
|
|
title = get_title(_read("practice_page.html"))
|
|
|
|
assert title == title.strip()
|
|
assert title.startswith("Instant Pot Duo Plus")
|
|
|
|
|
|
def test_get_price_raises_when_price_missing() -> None:
|
|
with pytest.raises(PriceNotFoundError):
|
|
get_price(_read("no_price_page.html"))
|
|
|
|
|
|
def test_get_title_raises_when_title_missing() -> None:
|
|
with pytest.raises(TitleNotFoundError):
|
|
get_title("<html></html>")
|
|
|
|
|
|
def test_get_price_raises_when_text_not_a_number() -> None:
|
|
html = '<span class="a-offscreen">$abc</span>'
|
|
|
|
with pytest.raises(ProductParseError):
|
|
get_price(html)
|
|
|
|
|
|
def test_parse_product_returns_product() -> None:
|
|
product = parse_product(_read("practice_page.html"))
|
|
|
|
assert isinstance(product, Product)
|
|
assert product.price == 99.99
|
|
|
|
|
|
def test_is_robot_check_true_for_robot_check_page() -> None:
|
|
assert is_robot_check(_read("amazon_robot_check.html"))
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["practice_page.html", "amazon_live_page.html"])
|
|
def test_is_robot_check_false_for_product_page(name: str) -> None:
|
|
assert not is_robot_check(_read(name))
|
|
|
|
|
|
def test_get_price_raises_robot_check_error_on_robot_check() -> None:
|
|
with pytest.raises(RobotCheckError):
|
|
get_price(_read("amazon_robot_check.html"))
|
|
|
|
|
|
def test_get_title_raises_robot_check_error_on_robot_check() -> None:
|
|
with pytest.raises(RobotCheckError):
|
|
get_title(_read("amazon_robot_check.html"))
|
|
|
|
|
|
def test_parse_product_raises_robot_check_error_on_robot_check() -> None:
|
|
with pytest.raises(RobotCheckError):
|
|
parse_product(_read("amazon_robot_check.html"))
|
|
|
|
|
|
def test_robot_check_error_is_a_product_parse_error() -> None:
|
|
assert issubclass(RobotCheckError, ProductParseError)
|
|
|
|
|
|
def test_get_price_skips_decoy_coupon_on_live_page() -> None:
|
|
assert get_price(_read("amazon_live_page.html")) == 79.95
|
|
|
|
|
|
def test_get_title_strips_title_on_live_page() -> None:
|
|
assert get_title(_read("amazon_live_page.html")) == "Instant Pot Duo 7-in-1"
|
|
|
|
|
|
def test_soup_product_parser_parses_html() -> None:
|
|
product = SoupProductParser().parse(_read("practice_page.html"))
|
|
|
|
assert product.price == 99.99
|