From 07b46f0180266dc7880841031737ef8a5151d5e6 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 14:44:18 +0800 Subject: [PATCH 1/2] MIL-003 e-mail alert (Step 2) Delivers MIL-003 (lecture Step 2): an alert e-mail with title, price and buy link is sent over SMTP (port 587, STARTTLS) when the price is below the target 100. SMTP address, e-mail address and password are read from `.env` with python-dotenv (`.env.example` lists them). Tests use fakes and never open a network or SMTP connection. Review: code against QC-PY-001 as RC-012 (Go, autonomous, not independent). 33 pytest tests, ruff and mypy --strict pass. The Step 2 screenshots come from a run with a stubbed SMTP connection; no real mail was sent. Task: MIL-003#1 Task: MIL-003#2 Task: MIL-003#3 Task: MIL-003#4 Task: MIL-003#5 --- README.md | 14 ++- docs/artifact-registry.md | 2 +- docs/images/step-2-email.png | Bin 0 -> 14737 bytes docs/images/step-2-run.png | Bin 0 -> 16757 bytes docs/sqa/reviews/rc-012-code-mil-003.md | 61 +++++++++++++ src/amazon_price_tracker/application/alert.py | 68 ++++++++++++++ src/amazon_price_tracker/application/ports.py | 11 ++- .../domain/alert_message.py | 17 ++++ src/amazon_price_tracker/domain/errors.py | 6 +- src/amazon_price_tracker/domain/pricing.py | 15 ++++ .../infrastructure/mailer.py | 54 +++++++++++ .../infrastructure/settings.py | 46 ++++++++++ src/amazon_price_tracker/main.py | 32 +++++-- tests/unit/application/test_alert.py | 78 ++++++++++++++++ tests/unit/domain/test_pricing.py | 13 +++ tests/unit/infrastructure/test_mailer.py | 85 ++++++++++++++++++ tests/unit/infrastructure/test_settings.py | 54 +++++++++++ 17 files changed, 544 insertions(+), 12 deletions(-) create mode 100644 docs/images/step-2-email.png create mode 100644 docs/images/step-2-run.png create mode 100644 docs/sqa/reviews/rc-012-code-mil-003.md create mode 100644 src/amazon_price_tracker/application/alert.py create mode 100644 src/amazon_price_tracker/domain/alert_message.py create mode 100644 src/amazon_price_tracker/domain/pricing.py create mode 100644 src/amazon_price_tracker/infrastructure/mailer.py create mode 100644 src/amazon_price_tracker/infrastructure/settings.py create mode 100644 tests/unit/application/test_alert.py create mode 100644 tests/unit/domain/test_pricing.py create mode 100644 tests/unit/infrastructure/test_mailer.py create mode 100644 tests/unit/infrastructure/test_settings.py diff --git a/README.md b/README.md index f6d7993..ea16521 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The assignment is built in four lecture steps. Each step was delivered on its ow | Step | What it adds | Status | | --- | --- | --- | | 1 | Scrape the price from the practice page with BeautifulSoup | done | -| 2 | E-mail alert below the target price, secrets in `.env` | planned (MIL-003) | +| 2 | E-mail alert below the target price, secrets in `.env` | done | | 3 | Browser-like request headers | planned (MIL-004) | | 4 | The live Amazon page | planned (MIL-005) | @@ -32,6 +32,16 @@ The assignment is built in four lecture steps. Each step was delivered on its ow ![Output of the bot: title and price as a float](docs/images/step-1-run.png) +### Step 2: e-mail alert below the target + +`check_price` in `src/amazon_price_tracker/application/alert.py` compares the price with `TARGET_PRICE` (100) from `constants.py`. When it is lower, `SmtpNotifier` (`infrastructure/mailer.py`) connects to your SMTP server on port 587, starts TLS, logs in and sends a message with the title, the price and the link to buy. The SMTP address, e-mail address and password come from your own `.env` through `python-dotenv`, so they never appear in the code. The practice page costs 99.99, so a target of 100 triggers the alert. + +The screenshots below come from a run where the SMTP connection is replaced by a recording stub, so no mail is sent; the second image is rendered from the message the code builds. + +![Run with a stubbed SMTP connection](docs/images/step-2-run.png) + +![The alert message](docs/images/step-2-email.png) + Repository layout: | Path | What it is for | @@ -96,7 +106,7 @@ Gmail needs 2-Step Verification and an App Password; use the App Password as `EM ## ▶️ Run -The runnable steps are added milestone by milestone (see the table above). After step 1 the bot starts with: +Fill in your `.env` first (see Setup), then start the bot. It prints the title and the price and sends the alert when the price is below the target: ```bash python -m amazon_price_tracker diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index bc3d944..f6c14b0 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -15,7 +15,7 @@ document of a type. `Primary File` may contain a glob (e.g. | SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 | | PP | Project Plan | docs/project-plan.md | 002 | | MIL | Milestone / Gateway | docs/milestones/*.md | 007 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 012 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 013 | ## Languages diff --git a/docs/images/step-2-email.png b/docs/images/step-2-email.png new file mode 100644 index 0000000000000000000000000000000000000000..0194d2ef2036a2aabd144510842c654e0905753b GIT binary patch literal 14737 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 literal 0 HcmV?d00001 diff --git a/docs/images/step-2-run.png b/docs/images/step-2-run.png new file mode 100644 index 0000000000000000000000000000000000000000..a91bd022085277343a04408ad42eeda9f5ec029b GIT binary patch literal 16757 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 VMw9k*17Zz{vVyvN=}XfO{|i;I+`Iq) literal 0 HcmV?d00001 diff --git a/docs/sqa/reviews/rc-012-code-mil-003.md b/docs/sqa/reviews/rc-012-code-mil-003.md new file mode 100644 index 0000000..b42272b --- /dev/null +++ b/docs/sqa/reviews/rc-012-code-mil-003.md @@ -0,0 +1,61 @@ +# RC-012 Code review of MIL-003 + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-012 | +| CrossReference | [MIL-003], [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | + +--- + +## Artifact Under Review + +- Instance reviewed: [MIL-003] +- Checklist used: [QC-PY-001] +- Scope: full review +- Language and domain: n/a (technical) +- Language reviewer: none +- Mode: autonomous, not independent (AI as stand-in for S01); the author and the stand-in are both the AI working for S01 + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Modules and functions are snake_case, classes PascalCase, constants UPPER_SNAKE | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (price, target, headers); no abbreviations left unexplained | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | ruff format and ruff check pass: 33 pytest tests with fake SMTP and fakes of the ports, ruff 0 findings, mypy strict 0 errors (26 files) | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | All signatures annotated; mypy --strict passes: 33 pytest tests with fake SMTP and fakes of the ports, ruff 0 findings, mypy strict 0 errors (26 files) | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare except; errors are specific and chained with `raise ... from` where a cause exists | +| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults, no shadowed builtins | +| 7 | Files, locks and connections are managed with context managers | Pass | The SMTP connection is used as a context manager in SmtpNotifier.send | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules and functions have docstrings with Doxygen tags | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No logging of secrets: the password is excluded from the settings repr and from error texts; print is used only for the assignment output and the missing-setting message | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists; the milestone's technical tasks need none (Needs use case: No) | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | Tests exist, are named for behaviour and use no network or real mail server: 33 pytest tests with fake SMTP and fakes of the ports, ruff 0 findings, mypy strict 0 errors (26 files) | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | mypy --strict passes; no Any | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | Dependencies are in pyproject.toml with lower bounds; none unused | +| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Constants live only in constants.py | +| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | Imports point inward; no cycles | +| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | Notifier, PageFetcher and ProductParser are narrow Protocols; AlertMessage lives in domain so ports and use case share it without a cycle | +| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | domain, application and infrastructure packages; main.py is the composition root; tests mirror them under tests/unit/ | +| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File names are English snake_case | + +## Overall Verdict + +Go — all Mandatory criteria pass or are not applicable. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| None: no Fail and no Pending criterion | S01 | 2026-10-28 | + +--- + +[MIL-003]: ../../milestones/mil-003-email-alert.md +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md diff --git a/src/amazon_price_tracker/application/alert.py b/src/amazon_price_tracker/application/alert.py new file mode 100644 index 0000000..721256a --- /dev/null +++ b/src/amazon_price_tracker/application/alert.py @@ -0,0 +1,68 @@ +"""Use case: alert when the price is below the target. + +@file alert.py +@brief Builds the alert message and sends it when the price is low enough. +""" + +from dataclasses import dataclass + +from amazon_price_tracker.application.ports import ( + Notifier, + PageFetcher, + ProductParser, +) +from amazon_price_tracker.application.read_product import read_product +from amazon_price_tracker.domain.alert_message import AlertMessage +from amazon_price_tracker.domain.pricing import is_below_target +from amazon_price_tracker.domain.product import Product + +## @brief Subject of every price alert. +ALERT_SUBJECT = "Amazon Price Alert!" + + +@dataclass(frozen=True, slots=True) +class PriceCheck: + """Result of a price check.""" + + ## @brief The product read from the page. + product: Product + ## @brief True when an alert was sent. + alert_sent: bool + + +def build_message(title: str, price: float, url: str) -> AlertMessage: + """Return the alert for a product. + + @param title Product title. + @param price Current price. + @param url Link to buy the product. + @return The message with title, price and link in the body. + """ + return AlertMessage( + subject=ALERT_SUBJECT, + body=f"{title} is now ${price:.2f}\n{url}", + ) + + +def check_price( + fetcher: PageFetcher, + parser: ProductParser, + notifier: Notifier, + url: str, + target: float, +) -> PriceCheck: + """Send an alert when the product at url costs less than target. + + @param fetcher Port that downloads the page. + @param parser Port that reads the product from the HTML. + @param notifier Port that sends the alert. + @param url Address of the product page. + @param target Price under which an alert is sent. + @return The product read and whether an alert was sent. + @throws ProductParseError When the page holds no readable product. + """ + product = read_product(fetcher, parser, url) + if not is_below_target(product.price, target): + return PriceCheck(product, alert_sent=False) + notifier.send(build_message(product.title, product.price, url)) + return PriceCheck(product, alert_sent=True) diff --git a/src/amazon_price_tracker/application/ports.py b/src/amazon_price_tracker/application/ports.py index b9cc95f..438f99f 100644 --- a/src/amazon_price_tracker/application/ports.py +++ b/src/amazon_price_tracker/application/ports.py @@ -1,11 +1,12 @@ """Ports the use cases need from the outside. @file ports.py -@brief Protocols for fetching a page and parsing a product from it. +@brief Protocols for fetching a page, parsing a product and sending an alert. """ from typing import Protocol +from amazon_price_tracker.domain.alert_message import AlertMessage from amazon_price_tracker.domain.product import Product @@ -23,3 +24,11 @@ class ProductParser(Protocol): def parse(self, html: str) -> Product: """Return the product described by html.""" ... + + +class Notifier(Protocol): + """Sends an alert message to the user.""" + + def send(self, message: AlertMessage) -> None: + """Deliver message to the user.""" + ... diff --git a/src/amazon_price_tracker/domain/alert_message.py b/src/amazon_price_tracker/domain/alert_message.py new file mode 100644 index 0000000..47d8483 --- /dev/null +++ b/src/amazon_price_tracker/domain/alert_message.py @@ -0,0 +1,17 @@ +"""The alert message. + +@file alert_message.py +@brief Defines the AlertMessage value object shared by use case and port. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class AlertMessage: + """A message to send to the user.""" + + ## @brief Subject line. + subject: str + ## @brief Plain text body. + body: str diff --git a/src/amazon_price_tracker/domain/errors.py b/src/amazon_price_tracker/domain/errors.py index 721297c..d2e3eaf 100644 --- a/src/amazon_price_tracker/domain/errors.py +++ b/src/amazon_price_tracker/domain/errors.py @@ -1,7 +1,7 @@ """Errors of the domain. @file errors.py -@brief Exceptions raised when a product cannot be read from a page. +@brief Exceptions raised when a product cannot be read or a setting is missing. """ @@ -15,3 +15,7 @@ class PriceNotFoundError(ProductParseError): class TitleNotFoundError(ProductParseError): """The page has no product title.""" + + +class MissingSettingError(Exception): + """A required setting is missing or empty.""" diff --git a/src/amazon_price_tracker/domain/pricing.py b/src/amazon_price_tracker/domain/pricing.py new file mode 100644 index 0000000..bba5b10 --- /dev/null +++ b/src/amazon_price_tracker/domain/pricing.py @@ -0,0 +1,15 @@ +"""Price rules. + +@file pricing.py +@brief Decides whether a price is low enough to alert on. +""" + + +def is_below_target(price: float, target: float) -> bool: + """Return True when price is strictly below target. + + @param price Current price. + @param target Price under which an alert is wanted. + @return True when price < target; an equal price is not below. + """ + return price < target diff --git a/src/amazon_price_tracker/infrastructure/mailer.py b/src/amazon_price_tracker/infrastructure/mailer.py new file mode 100644 index 0000000..034a98a --- /dev/null +++ b/src/amazon_price_tracker/infrastructure/mailer.py @@ -0,0 +1,54 @@ +"""SMTP adapter for the Notifier port. + +@file mailer.py +@brief Sends the alert to your own address with smtplib and STARTTLS. +""" + +import smtplib +from collections.abc import Callable +from email.message import EmailMessage + +from amazon_price_tracker.constants import SMTP_PORT +from amazon_price_tracker.domain.alert_message import AlertMessage +from amazon_price_tracker.infrastructure.settings import MailSettings + + +class SmtpNotifier: + """Notifier port implemented with smtplib.""" + + def __init__( + self, + settings: MailSettings, + smtp_factory: Callable[..., smtplib.SMTP] = smtplib.SMTP, + ) -> None: + """Keep the settings and the SMTP connection factory. + + @param settings Server, address and password. + @param smtp_factory Creates the connection; replaced in tests. + """ + self._settings = settings + self._smtp_factory = smtp_factory + + def _build(self, message: AlertMessage) -> bytes: + email = EmailMessage() + email["Subject"] = message.subject + email["From"] = self._settings.email_address + email["To"] = self._settings.email_address + email.set_content(message.body, charset="utf-8") + return email.as_bytes() + + def send(self, message: AlertMessage) -> None: + """Send message from the sender address to the same address. + + @param message Subject and body to send. + @throws smtplib.SMTPException When the server refuses the login or message. + """ + settings = self._settings + with self._smtp_factory(settings.smtp_address, port=SMTP_PORT) as connection: + connection.starttls() + connection.login(settings.email_address, settings.email_password) + connection.sendmail( + from_addr=settings.email_address, + to_addrs=settings.email_address, + msg=self._build(message), + ) diff --git a/src/amazon_price_tracker/infrastructure/settings.py b/src/amazon_price_tracker/infrastructure/settings.py new file mode 100644 index 0000000..b07af30 --- /dev/null +++ b/src/amazon_price_tracker/infrastructure/settings.py @@ -0,0 +1,46 @@ +"""Mail settings read from the environment. + +@file settings.py +@brief Builds MailSettings from a mapping of environment variables. +""" + +from collections.abc import Mapping +from dataclasses import dataclass, field + +from amazon_price_tracker.constants import ( + ENV_EMAIL_ADDRESS, + ENV_EMAIL_PASSWORD, + ENV_SMTP_ADDRESS, +) +from amazon_price_tracker.domain.errors import MissingSettingError + + +@dataclass(frozen=True, slots=True) +class MailSettings: + """Where and how to send the alert e-mail.""" + + smtp_address: str + email_address: str + # Kept out of repr so the secret never reaches logs or tracebacks. + email_password: str = field(repr=False) + + +def _require(environ: Mapping[str, str], name: str) -> str: + value = environ.get(name, "") + if not value.strip(): + raise MissingSettingError(f"Missing setting: {name}") + return value + + +def load_mail_settings(environ: Mapping[str, str]) -> MailSettings: + """Return the mail settings found in environ. + + @param environ Environment variables, for example os.environ. + @return The settings. + @throws MissingSettingError Naming the first missing or empty variable. + """ + return MailSettings( + smtp_address=_require(environ, ENV_SMTP_ADDRESS), + email_address=_require(environ, ENV_EMAIL_ADDRESS), + email_password=_require(environ, ENV_EMAIL_PASSWORD), + ) diff --git a/src/amazon_price_tracker/main.py b/src/amazon_price_tracker/main.py index c22284b..9210817 100644 --- a/src/amazon_price_tracker/main.py +++ b/src/amazon_price_tracker/main.py @@ -1,17 +1,35 @@ """Composition root. @file main.py -@brief Builds the adapters and prints the title and price of the product. +@brief Builds the adapters, prints the product and sends a price alert. """ -from amazon_price_tracker.application.read_product import read_product -from amazon_price_tracker.constants import PRACTICE_URL +import os +import sys + +from dotenv import find_dotenv, load_dotenv + +from amazon_price_tracker.application.alert import check_price +from amazon_price_tracker.constants import PRACTICE_URL, TARGET_PRICE +from amazon_price_tracker.domain.errors import MissingSettingError from amazon_price_tracker.infrastructure.http_client import RequestsPageFetcher +from amazon_price_tracker.infrastructure.mailer import SmtpNotifier from amazon_price_tracker.infrastructure.scraper import SoupProductParser +from amazon_price_tracker.infrastructure.settings import load_mail_settings def main() -> None: - """Print the title and the price of the practice product.""" - product = read_product(RequestsPageFetcher(), SoupProductParser(), PRACTICE_URL) - print(product.title) - print(product.price) + """Print the product and send an e-mail when its price is below target.""" + load_dotenv(find_dotenv(usecwd=True)) + try: + settings = load_mail_settings(os.environ) + except MissingSettingError as error: + print(f"{error}. Copy .env.example to .env and fill it in.", file=sys.stderr) + return + notifier = SmtpNotifier(settings) + result = check_price( + RequestsPageFetcher(), SoupProductParser(), notifier, PRACTICE_URL, TARGET_PRICE + ) + print(result.product.title) + print(result.product.price) + print("Alert sent" if result.alert_sent else "Price is not below the target") diff --git a/tests/unit/application/test_alert.py b/tests/unit/application/test_alert.py new file mode 100644 index 0000000..1d78536 --- /dev/null +++ b/tests/unit/application/test_alert.py @@ -0,0 +1,78 @@ +from amazon_price_tracker.application.alert import ( + PriceCheck, + build_message, + check_price, +) +from amazon_price_tracker.domain.alert_message import AlertMessage +from amazon_price_tracker.domain.product import Product + +URL = "https://example.test/p" + + +class FakeFetcher: + def __init__(self) -> None: + self.urls: list[str] = [] + + def fetch(self, url: str) -> str: + self.urls.append(url) + return "" + + +class FakeParser: + def __init__(self, product: Product) -> None: + self.product = product + + def parse(self, html: str) -> Product: + return self.product + + +class FakeNotifier: + def __init__(self) -> None: + self.sent: list[AlertMessage] = [] + + def send(self, message: AlertMessage) -> None: + self.sent.append(message) + + +def _run(price: float) -> tuple[PriceCheck, FakeNotifier, FakeFetcher]: + notifier = FakeNotifier() + fetcher = FakeFetcher() + result = check_price( + fetcher, FakeParser(Product("Pot", price)), notifier, URL, 100.0 + ) + return result, notifier, fetcher + + +def test_build_message_contains_title_price_and_link() -> None: + message = build_message("Instant Pot", 79.5, URL) + + assert message.subject == "Amazon Price Alert!" + assert "Instant Pot" in message.body + assert "$79.50" in message.body + assert URL in message.body + + +def test_check_price_sends_once_when_below_target() -> None: + result, notifier, fetcher = _run(99.99) + + assert result.alert_sent + assert result.product == Product("Pot", 99.99) + assert fetcher.urls == [URL] + assert len(notifier.sent) == 1 + assert "$99.99" in notifier.sent[0].body + + +def test_check_price_does_not_send_when_equal_to_target() -> None: + result, notifier, fetcher = _run(100.0) + + assert not result.alert_sent + assert fetcher.urls == [URL] + assert notifier.sent == [] + + +def test_check_price_does_not_send_when_above_target() -> None: + result, notifier, fetcher = _run(150.0) + + assert not result.alert_sent + assert fetcher.urls == [URL] + assert notifier.sent == [] diff --git a/tests/unit/domain/test_pricing.py b/tests/unit/domain/test_pricing.py new file mode 100644 index 0000000..589ef5a --- /dev/null +++ b/tests/unit/domain/test_pricing.py @@ -0,0 +1,13 @@ +from amazon_price_tracker.domain.pricing import is_below_target + + +def test_is_below_target_true_when_price_lower() -> None: + assert is_below_target(99.99, 100.0) + + +def test_is_below_target_false_when_price_equal() -> None: + assert not is_below_target(100.0, 100.0) + + +def test_is_below_target_false_when_price_higher() -> None: + assert not is_below_target(100.01, 100.0) diff --git a/tests/unit/infrastructure/test_mailer.py b/tests/unit/infrastructure/test_mailer.py new file mode 100644 index 0000000..21cd36d --- /dev/null +++ b/tests/unit/infrastructure/test_mailer.py @@ -0,0 +1,85 @@ +from email import message_from_bytes, policy +from types import TracebackType +from typing import Any, cast + +from amazon_price_tracker.constants import SMTP_PORT +from amazon_price_tracker.domain.alert_message import AlertMessage +from amazon_price_tracker.infrastructure.mailer import SmtpNotifier +from amazon_price_tracker.infrastructure.settings import MailSettings + + +class FakeSmtp: + def __init__(self, host: str, port: int) -> None: + self.host = host + self.port = port + self.calls: list[str] = [] + self.login_args: tuple[str, str] | None = None + self.mail: dict[str, Any] = {} + self.closed = False + + def __enter__(self) -> "FakeSmtp": + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.closed = True + + def starttls(self) -> None: + self.calls.append("starttls") + + def login(self, user: str, password: str) -> None: + self.calls.append("login") + self.login_args = (user, password) + + def sendmail(self, from_addr: str, to_addrs: str, msg: bytes) -> None: + self.calls.append("sendmail") + self.mail = {"from": from_addr, "to": to_addrs, "msg": msg} + + +def _send(message: AlertMessage) -> FakeSmtp: + created: list[FakeSmtp] = [] + + def factory(host: str, port: int) -> FakeSmtp: + created.append(FakeSmtp(host, port)) + return created[0] + + settings = MailSettings("smtp.example.test", "me@example.test", "pw") + SmtpNotifier(settings, smtp_factory=cast(Any, factory)).send(message) + return created[0] + + +def test_send_connects_to_host_on_starttls_port() -> None: + smtp = _send(AlertMessage("Subject", "Body")) + + assert smtp.host == "smtp.example.test" + assert smtp.port == SMTP_PORT == 587 + assert smtp.closed + + +def test_send_runs_starttls_login_sendmail_in_order() -> None: + smtp = _send(AlertMessage("Subject", "Body")) + + assert smtp.calls == ["starttls", "login", "sendmail"] + assert smtp.login_args == ("me@example.test", "pw") + + +def test_send_mails_to_own_address_with_utf8_content() -> None: + body = "Pot æøå is now $9.50\nhttps://example.test/p" + smtp = _send(AlertMessage("Amazon Price Alert!", body)) + + assert smtp.mail["from"] == "me@example.test" + assert smtp.mail["to"] == "me@example.test" + raw = smtp.mail["msg"] + assert isinstance(raw, bytes) + parsed = message_from_bytes(raw, policy=policy.default) + assert parsed["Subject"] == "Amazon Price Alert!" + assert parsed["From"] == parsed["To"] == "me@example.test" + content = parsed.get_content() + assert "Pot æøå" in content + assert "$9.50" in content + assert "https://example.test/p" in content + assert parsed.get_content_charset() == "utf-8" diff --git a/tests/unit/infrastructure/test_settings.py b/tests/unit/infrastructure/test_settings.py new file mode 100644 index 0000000..c21ec52 --- /dev/null +++ b/tests/unit/infrastructure/test_settings.py @@ -0,0 +1,54 @@ +import pytest + +from amazon_price_tracker.constants import ( + ENV_EMAIL_ADDRESS, + ENV_EMAIL_PASSWORD, + ENV_SMTP_ADDRESS, +) +from amazon_price_tracker.domain.errors import MissingSettingError +from amazon_price_tracker.infrastructure.settings import load_mail_settings + +FULL = { + ENV_SMTP_ADDRESS: "smtp.example.test", + ENV_EMAIL_ADDRESS: "me@example.test", + ENV_EMAIL_PASSWORD: "s3cret-pw", +} + + +def test_load_mail_settings_reads_all_values() -> None: + settings = load_mail_settings(FULL) + + assert settings.smtp_address == "smtp.example.test" + assert settings.email_address == "me@example.test" + assert settings.email_password == "s3cret-pw" + + +@pytest.mark.parametrize( + "name", [ENV_SMTP_ADDRESS, ENV_EMAIL_ADDRESS, ENV_EMAIL_PASSWORD] +) +def test_load_mail_settings_raises_naming_missing_variable(name: str) -> None: + environ = {key: value for key, value in FULL.items() if key != name} + + with pytest.raises(MissingSettingError, match=name): + load_mail_settings(environ) + + +def test_load_mail_settings_raises_on_empty_value() -> None: + with pytest.raises(MissingSettingError, match=ENV_EMAIL_ADDRESS): + load_mail_settings({**FULL, ENV_EMAIL_ADDRESS: ""}) + + +def test_load_mail_settings_names_first_missing_variable() -> None: + with pytest.raises(MissingSettingError, match=ENV_SMTP_ADDRESS): + load_mail_settings({}) + + +def test_mail_settings_repr_hides_password() -> None: + assert "s3cret-pw" not in repr(load_mail_settings(FULL)) + + +def test_missing_setting_error_does_not_contain_password() -> None: + with pytest.raises(MissingSettingError) as info: + load_mail_settings({**FULL, ENV_SMTP_ADDRESS: ""}) + + assert "s3cret-pw" not in str(info.value) -- 2.54.0 From aa0cdf0847cc30a1e66f9d344f70c5678d6eb26c Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 14:44:22 +0800 Subject: [PATCH 2/2] Resolve pending commit links of MIL-003 --- docs/sqa/reviews/rc-012-code-mil-003.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sqa/reviews/rc-012-code-mil-003.md b/docs/sqa/reviews/rc-012-code-mil-003.md index b42272b..497b726 100644 --- a/docs/sqa/reviews/rc-012-code-mil-003.md +++ b/docs/sqa/reviews/rc-012-code-mil-003.md @@ -9,7 +9,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | +| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [07b46f0] | --- @@ -59,3 +59,4 @@ Go — all Mandatory criteria pass or are not applicable. [MIL-003]: ../../milestones/mil-003-email-alert.md [QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[07b46f0]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/047-amazon-price-tracker/commit/07b46f0180266dc7880841031737ef8a5151d5e6 -- 2.54.0