MIL-002: birthday wisher core #20
@@ -15,7 +15,7 @@ document of a type. `Primary File` may contain a glob (e.g.
|
|||||||
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
|
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
|
||||||
| PP | Project Plan | docs/project-plan.md | 002 |
|
| PP | Project Plan | docs/project-plan.md | 002 |
|
||||||
| MIL | Milestone / Gateway | docs/milestones/*.md | 004 |
|
| MIL | Milestone / Gateway | docs/milestones/*.md | 004 |
|
||||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 006 |
|
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 007 |
|
||||||
|
|
||||||
## Languages
|
## Languages
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# SQA Review Record
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | RC-006 |
|
||||||
|
| CrossReference | [MIL-002], [QC-PY-001] |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-17 | Proposed | Jens Tirsvad Nielsen | S01 | Initial version | pending |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifact Under Review
|
||||||
|
|
||||||
|
- Instance reviewed: [MIL-002]
|
||||||
|
- Checklist used: [QC-PY-001]
|
||||||
|
- Scope: full review of `src/` and `tests/` (MIL-002 task 6)
|
||||||
|
- Language and domain: n/a (technical type)
|
||||||
|
- Language reviewer: none (the reviewer is the stand-in for S01, who reads English and knows the IT domain)
|
||||||
|
- Mode: autonomous, not independent (reviewer: AI as stand-in 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, functions and variables are snake_case, classes PascalCase, constants UPPER_SNAKE; ruff (E, F, I, B, UP) agrees. |
|
||||||
|
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (`find_birthday_people`, `send_birthday_wishes`, `read_credentials`); no cryptic abbreviations. |
|
||||||
|
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` pass; config is in `pyproject.toml`; no suppressions. |
|
||||||
|
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy --strict src tests` reports no issues; every signature is annotated. |
|
||||||
|
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; `MailError`, `BirthdayDataError`, `LetterTemplateError` are raised `from` their cause. |
|
||||||
|
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable default arguments (dataclass fields use `default_factory`); no builtin shadowed. |
|
||||||
|
| 7 | Files, locks and connections are managed with context managers | Pass | CSV file and SMTP connection are used in `with` blocks. |
|
||||||
|
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Every public module, class and function has a Doxygen docstring (`@brief`, `@param`, `@return`). |
|
||||||
|
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | `print` is used only in `presentation/cli.py` for the program output the user asked for (dry-run mail, status lines, errors to stderr); it is not diagnostic logging. The token is never printed (tested in `test_cli.py`). |
|
||||||
|
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists: the plan has only plain technical tasks and no use case (recorded in the Project Plan, Open Issues). The structure follows `clean-architecture`. Stand-in decision, open to challenge by S01. |
|
||||||
|
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 56 tests pass offline in 0.2 s; they use fakes and `tmp_path`, are named for behaviour and do not read `.env`. |
|
||||||
|
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | Strict mode passes. `Any` appears twice for the SMTP factory type, each with a comment. |
|
||||||
|
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies = []`; dev extras are pytest, ruff and mypy with lower bounds. Exact pins are not set because the project has no runtime dependencies. |
|
||||||
|
| 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 | The placeholder, SMTP settings, subject and file names are defined once 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 | `domain` imports nothing from other layers; `application` imports `domain`; `infrastructure` and `presentation` import 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 | One reason to change per module; ports are narrow protocols; the use case receives its collaborators. |
|
||||||
|
| 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`, `infrastructure`, `presentation` under `src/birthday_wisher/`; `unit`, `integration`, `e2e` under `tests/`. |
|
||||||
|
| 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 | All names are English, lower-case snake_case, with no spaces. |
|
||||||
|
|
||||||
|
## Overall Verdict
|
||||||
|
|
||||||
|
Go - All applicable criteria pass; criterion 10 is N-A because no Design Class Diagram exists (stand-in decision).
|
||||||
|
|
||||||
|
## Action Items
|
||||||
|
|
||||||
|
| Action | Owner | Due |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Decide whether a Design Class Diagram is wanted for this small project | S01 | 2026-10-20 |
|
||||||
|
|
||||||
|
---
|
||||||
|
[MIL-002]: ../../milestones/mil-002-birthday-wisher-core.md
|
||||||
|
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||||
+36
-22
@@ -1,22 +1,36 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=68"]
|
requires = ["setuptools>=68"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "birthday-wisher"
|
name = "birthday-wisher"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Sends a random birthday letter by e-mail to everyone whose birthday is today (Udemy 100 Days of Code, day 32)."
|
description = "Sends a random birthday letter by e-mail to everyone whose birthday is today (Udemy 100 Days of Code, day 32)."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.scripts]
|
||||||
dev = ["pytest>=8"]
|
birthday-wisher = "birthday_wisher.presentation.cli:main"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[project.optional-dependencies]
|
||||||
where = ["src"]
|
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.setuptools.packages.find]
|
||||||
pythonpath = ["src"]
|
where = ["src"]
|
||||||
testpaths = ["tests"]
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 88
|
||||||
|
src = ["src", "tests"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "B", "UP"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
strict = true
|
||||||
|
mypy_path = "src"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""!
|
"""!
|
||||||
@file __init__.py
|
@file __init__.py
|
||||||
@brief Birthday wisher: sends a random birthday letter to everyone whose birthday is today.
|
@brief Birthday wisher: sends a random birthday letter on a person's birthday.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""!
|
||||||
|
@file __main__.py
|
||||||
|
@brief Allows `python -m birthday_wisher`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from birthday_wisher.presentation.cli import main
|
||||||
|
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""!
|
||||||
|
@file __init__.py
|
||||||
|
@brief Application layer: the use case and the ports it needs.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""!
|
||||||
|
@file ports.py
|
||||||
|
@brief Ports: what the use case needs from the outside world.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from birthday_wisher.domain.person import Person
|
||||||
|
|
||||||
|
|
||||||
|
class BirthdayRepository(Protocol):
|
||||||
|
"""!
|
||||||
|
@brief Source of the people and their birth dates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def load_all(self) -> list[Person]:
|
||||||
|
"""!
|
||||||
|
@brief Read every person.
|
||||||
|
@return All people, in file order.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class LetterSource(Protocol):
|
||||||
|
"""!
|
||||||
|
@brief Source of the letter templates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def read_letter(self, number: int) -> str:
|
||||||
|
"""!
|
||||||
|
@brief Read one template.
|
||||||
|
@param number Template number, 1 to LETTER_COUNT.
|
||||||
|
@return The template text with its placeholder.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class MailSender(Protocol):
|
||||||
|
"""!
|
||||||
|
@brief Sends one e-mail.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def send(self, recipient: str, subject: str, body: str) -> None:
|
||||||
|
"""!
|
||||||
|
@brief Send a plain-text e-mail.
|
||||||
|
@param recipient Address to send to.
|
||||||
|
@param subject Subject line.
|
||||||
|
@param body Message text.
|
||||||
|
@throws MailError when the mail cannot be sent.
|
||||||
|
"""
|
||||||
|
...
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""!
|
||||||
|
@file __init__.py
|
||||||
|
@brief Use cases of the birthday wisher.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""!
|
||||||
|
@file send_birthday_wishes.py
|
||||||
|
@brief Use case: send a random birthday letter to everyone whose birthday is today.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from birthday_wisher import constants
|
||||||
|
from birthday_wisher.application.ports import (
|
||||||
|
BirthdayRepository,
|
||||||
|
LetterSource,
|
||||||
|
MailSender,
|
||||||
|
)
|
||||||
|
from birthday_wisher.domain.errors import MailError
|
||||||
|
from birthday_wisher.domain.person import Person
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WishReport:
|
||||||
|
"""!
|
||||||
|
@brief Outcome of one run.
|
||||||
|
@param sent Names of the people who were sent a letter.
|
||||||
|
@param failed Names of the people whose letter could not be sent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
sent: list[str] = field(default_factory=list)
|
||||||
|
failed: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def birthdays_by_day(people: list[Person]) -> dict[tuple[int, int], list[Person]]:
|
||||||
|
"""!
|
||||||
|
@brief Build the dictionary the course hints describe.
|
||||||
|
@details The key is (month, day). The value is a list, because two people
|
||||||
|
can share a birthday.
|
||||||
|
@param people All people.
|
||||||
|
@return Mapping from (month, day) to the people born on that day.
|
||||||
|
"""
|
||||||
|
result: dict[tuple[int, int], list[Person]] = {}
|
||||||
|
for person in people:
|
||||||
|
result.setdefault(person.birthday, []).append(person)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def find_birthday_people(people: list[Person], today: date) -> list[Person]:
|
||||||
|
"""!
|
||||||
|
@brief Select the people whose birthday is on the given day.
|
||||||
|
@param people All people.
|
||||||
|
@param today The day to check.
|
||||||
|
@return The people to wish a happy birthday, in file order.
|
||||||
|
"""
|
||||||
|
return [person for person in people if person.has_birthday_on(today)]
|
||||||
|
|
||||||
|
|
||||||
|
def build_letter(template: str, name: str) -> str:
|
||||||
|
"""!
|
||||||
|
@brief Put the person's name into a letter template.
|
||||||
|
@param template Template text containing the name placeholder.
|
||||||
|
@param name Name of the recipient.
|
||||||
|
@return The finished letter.
|
||||||
|
"""
|
||||||
|
return template.replace(constants.NAME_PLACEHOLDER, name)
|
||||||
|
|
||||||
|
|
||||||
|
def send_birthday_wishes(
|
||||||
|
repository: BirthdayRepository,
|
||||||
|
letters: LetterSource,
|
||||||
|
mailer: MailSender,
|
||||||
|
today: date,
|
||||||
|
choose_letter: Callable[[], int],
|
||||||
|
) -> WishReport:
|
||||||
|
"""!
|
||||||
|
@brief Send one random letter to everyone whose birthday is today.
|
||||||
|
@param repository Source of the people.
|
||||||
|
@param letters Source of the letter templates.
|
||||||
|
@param mailer Sends the e-mail.
|
||||||
|
@param today The day to check.
|
||||||
|
@param choose_letter Returns the number of the template to use (1 to LETTER_COUNT).
|
||||||
|
@return Who was sent a letter and for whom sending failed.
|
||||||
|
"""
|
||||||
|
report = WishReport()
|
||||||
|
for person in find_birthday_people(repository.load_all(), today):
|
||||||
|
body = build_letter(letters.read_letter(choose_letter()), person.name)
|
||||||
|
try:
|
||||||
|
mailer.send(person.email, constants.EMAIL_SUBJECT, body)
|
||||||
|
except MailError:
|
||||||
|
report.failed.append(person.name)
|
||||||
|
else:
|
||||||
|
report.sent.append(person.name)
|
||||||
|
return report
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""!
|
||||||
|
@file __init__.py
|
||||||
|
@brief Domain layer: the business rules, independent of files, mail and the console.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""!
|
||||||
|
@file errors.py
|
||||||
|
@brief Errors the birthday wisher raises.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class BirthdayWisherError(Exception):
|
||||||
|
"""!
|
||||||
|
@brief Base class of every error raised by this program.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class BirthdayDataError(BirthdayWisherError):
|
||||||
|
"""!
|
||||||
|
@brief The birthdays file is missing or a row in it is invalid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class LetterTemplateError(BirthdayWisherError):
|
||||||
|
"""!
|
||||||
|
@brief A letter template is missing or unreadable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigurationError(BirthdayWisherError):
|
||||||
|
"""!
|
||||||
|
@brief A required setting (sender address or token) is missing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class MailError(BirthdayWisherError):
|
||||||
|
"""!
|
||||||
|
@brief A letter could not be sent.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""!
|
||||||
|
@file person.py
|
||||||
|
@brief The Person entity: a name, an e-mail address and a birth date.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import calendar
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Person:
|
||||||
|
"""!
|
||||||
|
@brief A person who should receive a birthday wish.
|
||||||
|
@param name Name that replaces the placeholder in the letter.
|
||||||
|
@param email Address the letter is sent to.
|
||||||
|
@param year Year of birth.
|
||||||
|
@param month Month of birth (1 to 12).
|
||||||
|
@param day Day of birth (1 to 31).
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
year: int
|
||||||
|
month: int
|
||||||
|
day: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def birthday(self) -> tuple[int, int]:
|
||||||
|
"""!
|
||||||
|
@brief The (month, day) pair the course hints use as dictionary key.
|
||||||
|
@return Tuple of month and day of birth.
|
||||||
|
"""
|
||||||
|
return (self.month, self.day)
|
||||||
|
|
||||||
|
def has_birthday_on(self, today: date) -> bool:
|
||||||
|
"""!
|
||||||
|
@brief Tell whether the given day is this person's birthday.
|
||||||
|
@details A birthday on 29 February is celebrated on 28 February in
|
||||||
|
years that are not leap years.
|
||||||
|
@param today The day to check.
|
||||||
|
@return True when month and day match.
|
||||||
|
"""
|
||||||
|
if self.birthday == (2, 29) and not calendar.isleap(today.year):
|
||||||
|
return (today.month, today.day) == (2, 28)
|
||||||
|
return (today.month, today.day) == self.birthday
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""!
|
||||||
|
@file __init__.py
|
||||||
|
@brief Infrastructure layer: files, SMTP and environment settings.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""!
|
||||||
|
@file csv_birthday_repository.py
|
||||||
|
@brief Reads the people from the birthdays CSV file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from birthday_wisher import constants
|
||||||
|
from birthday_wisher.domain.errors import BirthdayDataError
|
||||||
|
from birthday_wisher.domain.person import Person
|
||||||
|
|
||||||
|
|
||||||
|
class CsvBirthdayRepository:
|
||||||
|
"""!
|
||||||
|
@brief BirthdayRepository backed by a CSV file with the columns of CSV_COLUMNS.
|
||||||
|
@param path Path of the CSV file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
self._path = path
|
||||||
|
|
||||||
|
def load_all(self) -> list[Person]:
|
||||||
|
"""!
|
||||||
|
@brief Read every row of the file.
|
||||||
|
@return All people, in file order.
|
||||||
|
@throws BirthdayDataError when the file is missing or a row is invalid.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with self._path.open(newline="", encoding="utf-8") as handle:
|
||||||
|
reader = csv.DictReader(handle)
|
||||||
|
missing = set(constants.CSV_COLUMNS) - set(reader.fieldnames or [])
|
||||||
|
if missing:
|
||||||
|
raise BirthdayDataError(
|
||||||
|
f"{self._path}: missing column(s) {sorted(missing)}"
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
self._to_person(row, number)
|
||||||
|
for number, row in enumerate(reader, start=2)
|
||||||
|
]
|
||||||
|
except OSError as err:
|
||||||
|
raise BirthdayDataError(f"cannot read {self._path}: {err}") from err
|
||||||
|
|
||||||
|
def _to_person(self, row: dict[str, str], line: int) -> Person:
|
||||||
|
try:
|
||||||
|
return Person(
|
||||||
|
name=row["name"].strip(),
|
||||||
|
email=row["email"].strip(),
|
||||||
|
year=int(row["year"]),
|
||||||
|
month=int(row["month"]),
|
||||||
|
day=int(row["day"]),
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError, AttributeError) as err:
|
||||||
|
raise BirthdayDataError(f"{self._path} line {line}: invalid row") from err
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""!
|
||||||
|
@file letter_templates.py
|
||||||
|
@brief Reads the letter templates from a folder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from birthday_wisher import constants
|
||||||
|
from birthday_wisher.domain.errors import LetterTemplateError
|
||||||
|
|
||||||
|
|
||||||
|
class FolderLetterSource:
|
||||||
|
"""!
|
||||||
|
@brief LetterSource backed by the files letter_1.txt, letter_2.txt, and so on.
|
||||||
|
@param folder Folder that holds the templates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, folder: Path) -> None:
|
||||||
|
self._folder = folder
|
||||||
|
|
||||||
|
def read_letter(self, number: int) -> str:
|
||||||
|
"""!
|
||||||
|
@brief Read one template.
|
||||||
|
@param number Template number, 1 to LETTER_COUNT.
|
||||||
|
@return The template text.
|
||||||
|
@throws LetterTemplateError when the file cannot be read.
|
||||||
|
"""
|
||||||
|
path = self._folder / constants.LETTER_FILE_PATTERN.format(number=number)
|
||||||
|
try:
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
except OSError as err:
|
||||||
|
raise LetterTemplateError(
|
||||||
|
f"cannot read letter template {path}: {err}"
|
||||||
|
) from err
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""!
|
||||||
|
@file settings.py
|
||||||
|
@brief Reads the sender's address and token from the environment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from birthday_wisher import constants
|
||||||
|
from birthday_wisher.domain.errors import ConfigurationError
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Credentials:
|
||||||
|
"""!
|
||||||
|
@brief The sender's login.
|
||||||
|
@param address Sender e-mail address.
|
||||||
|
@param token Password or app password; hidden from repr.
|
||||||
|
"""
|
||||||
|
|
||||||
|
address: str
|
||||||
|
token: str
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"Credentials(address={self.address!r}, token='***')"
|
||||||
|
|
||||||
|
|
||||||
|
def read_credentials(environ: Mapping[str, str]) -> Credentials:
|
||||||
|
"""!
|
||||||
|
@brief Read the credentials from an environment mapping.
|
||||||
|
@details Only the names of the missing variables are reported, never a value.
|
||||||
|
@param environ Environment variables, normally os.environ.
|
||||||
|
@return The credentials.
|
||||||
|
@throws ConfigurationError when a variable is missing or empty.
|
||||||
|
"""
|
||||||
|
names = (constants.ENV_SENDER_ADDRESS, constants.ENV_SENDER_TOKEN)
|
||||||
|
missing = [name for name in names if not environ.get(name)]
|
||||||
|
if missing:
|
||||||
|
raise ConfigurationError(
|
||||||
|
f"missing environment variable(s): {', '.join(missing)}"
|
||||||
|
)
|
||||||
|
return Credentials(environ[names[0]], environ[names[1]])
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""!
|
||||||
|
@file smtp_mailer.py
|
||||||
|
@brief Sends e-mail through an SMTP server with STARTTLS.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import smtplib
|
||||||
|
from collections.abc import Callable
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from typing import Any # Any: smtplib.SMTP and test fakes share no common type
|
||||||
|
|
||||||
|
from birthday_wisher.domain.errors import MailError
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpMailer:
|
||||||
|
"""!
|
||||||
|
@brief MailSender that talks SMTP with STARTTLS.
|
||||||
|
@param host SMTP server name.
|
||||||
|
@param port SMTP port that expects STARTTLS.
|
||||||
|
@param address Sender address, also used to log in.
|
||||||
|
@param token Password or app password. Never logged or shown.
|
||||||
|
@param smtp_factory Creates the SMTP connection; tests replace it with a fake.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
address: str,
|
||||||
|
token: str,
|
||||||
|
smtp_factory: Callable[[str, int], Any] = smtplib.SMTP,
|
||||||
|
) -> None:
|
||||||
|
self._host = host
|
||||||
|
self._port = port
|
||||||
|
self._address = address
|
||||||
|
self._token = token
|
||||||
|
self._smtp_factory = smtp_factory
|
||||||
|
|
||||||
|
def send(self, recipient: str, subject: str, body: str) -> None:
|
||||||
|
"""!
|
||||||
|
@brief Connect, start TLS, log in and send one plain-text e-mail.
|
||||||
|
@param recipient Address to send to.
|
||||||
|
@param subject Subject line.
|
||||||
|
@param body Message text.
|
||||||
|
@throws MailError when the connection, login or sending fails.
|
||||||
|
"""
|
||||||
|
message = EmailMessage()
|
||||||
|
message["From"] = self._address
|
||||||
|
message["To"] = recipient
|
||||||
|
message["Subject"] = subject
|
||||||
|
message.set_content(body)
|
||||||
|
try:
|
||||||
|
with self._smtp_factory(self._host, self._port) as connection:
|
||||||
|
connection.starttls()
|
||||||
|
connection.login(self._address, self._token)
|
||||||
|
connection.send_message(message)
|
||||||
|
except (smtplib.SMTPException, OSError) as err:
|
||||||
|
raise MailError(
|
||||||
|
f"could not send to {recipient}: {type(err).__name__}"
|
||||||
|
) from err
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""!
|
||||||
|
@file __init__.py
|
||||||
|
@brief Presentation layer: the command line and the composition root.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""!
|
||||||
|
@file cli.py
|
||||||
|
@brief Command line entry point and composition root.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any # Any: smtplib.SMTP and test fakes share no common type
|
||||||
|
|
||||||
|
from birthday_wisher import constants
|
||||||
|
from birthday_wisher.application.ports import MailSender
|
||||||
|
from birthday_wisher.application.use_cases.send_birthday_wishes import (
|
||||||
|
send_birthday_wishes,
|
||||||
|
)
|
||||||
|
from birthday_wisher.domain.errors import BirthdayWisherError
|
||||||
|
from birthday_wisher.infrastructure.csv_birthday_repository import (
|
||||||
|
CsvBirthdayRepository,
|
||||||
|
)
|
||||||
|
from birthday_wisher.infrastructure.letter_templates import FolderLetterSource
|
||||||
|
from birthday_wisher.infrastructure.settings import read_credentials
|
||||||
|
from birthday_wisher.infrastructure.smtp_mailer import SmtpMailer
|
||||||
|
|
||||||
|
|
||||||
|
class _DryRunMailer:
|
||||||
|
"""!
|
||||||
|
@brief MailSender that prints the e-mail instead of sending it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def send(self, recipient: str, subject: str, body: str) -> None:
|
||||||
|
"""!
|
||||||
|
@brief Print the e-mail.
|
||||||
|
@param recipient Address the e-mail would go to.
|
||||||
|
@param subject Subject line.
|
||||||
|
@param body Message text.
|
||||||
|
"""
|
||||||
|
print(f"[dry run] To: {recipient}\nSubject: {subject}\n\n{body}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
"""!
|
||||||
|
@brief Create the argument parser.
|
||||||
|
@return The parser with --dry-run, --today, --csv and --letters.
|
||||||
|
"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="birthday-wisher",
|
||||||
|
description="Send a random birthday letter to everyone born on today's date.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="print the e-mails instead of sending them",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--today",
|
||||||
|
type=date.fromisoformat,
|
||||||
|
default=None,
|
||||||
|
help="pretend today is YYYY-MM-DD",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--csv",
|
||||||
|
type=Path,
|
||||||
|
default=Path(constants.BIRTHDAYS_FILE),
|
||||||
|
help="birthdays CSV file",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--letters",
|
||||||
|
type=Path,
|
||||||
|
default=Path(constants.LETTER_TEMPLATES_DIR),
|
||||||
|
help="folder with the letter templates",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def _choose_letter() -> int:
|
||||||
|
return random.randint(1, constants.LETTER_COUNT)
|
||||||
|
|
||||||
|
|
||||||
|
def main(
|
||||||
|
argv: Sequence[str] | None = None,
|
||||||
|
environ: Mapping[str, str] | None = None,
|
||||||
|
smtp_factory: Callable[[str, int], Any] | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""!
|
||||||
|
@brief Run the program.
|
||||||
|
@param argv Command line arguments; sys.argv is used when None.
|
||||||
|
@param environ Environment variables; os.environ is used when None.
|
||||||
|
@param smtp_factory Creates the SMTP connection; tests pass a fake.
|
||||||
|
@return Exit status: 0 on success, 1 when a letter failed or setup is wrong.
|
||||||
|
"""
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
env = os.environ if environ is None else environ
|
||||||
|
today = args.today or date.today()
|
||||||
|
try:
|
||||||
|
mailer: MailSender
|
||||||
|
if args.dry_run:
|
||||||
|
mailer = _DryRunMailer()
|
||||||
|
else:
|
||||||
|
credentials = read_credentials(env)
|
||||||
|
mailer = SmtpMailer(
|
||||||
|
constants.SMTP_HOST,
|
||||||
|
constants.SMTP_PORT,
|
||||||
|
credentials.address,
|
||||||
|
credentials.token,
|
||||||
|
**({} if smtp_factory is None else {"smtp_factory": smtp_factory}),
|
||||||
|
)
|
||||||
|
report = send_birthday_wishes(
|
||||||
|
CsvBirthdayRepository(args.csv),
|
||||||
|
FolderLetterSource(args.letters),
|
||||||
|
mailer,
|
||||||
|
today,
|
||||||
|
_choose_letter,
|
||||||
|
)
|
||||||
|
except BirthdayWisherError as err:
|
||||||
|
print(f"error: {err}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not report.sent and not report.failed:
|
||||||
|
print(f"No birthdays today ({today.isoformat()}).")
|
||||||
|
for name in report.sent:
|
||||||
|
print(f"Sent a birthday letter to {name}.")
|
||||||
|
for name in report.failed:
|
||||||
|
print(f"error: could not send a letter to {name}.", file=sys.stderr)
|
||||||
|
return 1 if report.failed else 0
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""End-to-end tests of the command line: real files, fake SMTP, no network."""
|
||||||
|
|
||||||
|
import smtplib
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from pathlib import Path
|
||||||
|
from types import TracebackType
|
||||||
|
from typing import ClassVar, Self
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from birthday_wisher import constants
|
||||||
|
from birthday_wisher.presentation.cli import main
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
ENV = {
|
||||||
|
constants.ENV_SENDER_ADDRESS: "me@example.com",
|
||||||
|
constants.ENV_SENDER_TOKEN: "tok-123",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSmtp:
|
||||||
|
"""Records the messages sent through every instance."""
|
||||||
|
|
||||||
|
messages: ClassVar[list[EmailMessage]] = []
|
||||||
|
fail = False
|
||||||
|
|
||||||
|
def __init__(self, host: str, port: int) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self) -> Self:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(
|
||||||
|
self,
|
||||||
|
exc_type: type[BaseException] | None,
|
||||||
|
exc: BaseException | None,
|
||||||
|
tb: TracebackType | None,
|
||||||
|
) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def starttls(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def login(self, user: str, password: str) -> None:
|
||||||
|
if FakeSmtp.fail:
|
||||||
|
raise smtplib.SMTPAuthenticationError(535, b"no")
|
||||||
|
|
||||||
|
def send_message(self, message: EmailMessage) -> None:
|
||||||
|
FakeSmtp.messages.append(message)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset() -> None:
|
||||||
|
FakeSmtp.messages = []
|
||||||
|
FakeSmtp.fail = False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def data(tmp_path: Path) -> list[str]:
|
||||||
|
"""Arguments for a temporary CSV (Bob and Carol on 10-17) and real letters."""
|
||||||
|
csv_path = tmp_path / "b.csv"
|
||||||
|
csv_path.write_text(
|
||||||
|
"name,email,year,month,day\n"
|
||||||
|
"Alice,alice@example.com,1990,3,14\n"
|
||||||
|
"Bob,bob@example.com,1985,10,17\n"
|
||||||
|
"Carol,carol@example.com,2001,10,17\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return ["--csv", str(csv_path), "--letters", str(ROOT / "letter_templates")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_prints_to_and_subject_without_env(
|
||||||
|
data: list[str], capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Dry run needs no environment and prints finished letters."""
|
||||||
|
code = main([*data, "--today", "2026-10-17", "--dry-run"], environ={})
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert code == 0
|
||||||
|
assert "To: bob@example.com" in out
|
||||||
|
assert "To: carol@example.com" in out
|
||||||
|
assert "Subject: Happy Birthday" in out
|
||||||
|
assert "[NAME]" not in out
|
||||||
|
assert "alice@example.com" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_mode_sends_through_fake_smtp(
|
||||||
|
data: list[str], capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Real mode sends one mail per birthday person through the factory."""
|
||||||
|
code = main([*data, "--today", "2026-10-17"], environ=ENV, smtp_factory=FakeSmtp)
|
||||||
|
assert code == 0
|
||||||
|
assert sorted(str(m["To"]) for m in FakeSmtp.messages) == [
|
||||||
|
"bob@example.com",
|
||||||
|
"carol@example.com",
|
||||||
|
]
|
||||||
|
assert all(m["Subject"] == "Happy Birthday" for m in FakeSmtp.messages)
|
||||||
|
assert "Sent a birthday letter to Bob." in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_env_returns_1_naming_variable_not_value(
|
||||||
|
data: list[str], capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""A missing variable is named, its sibling's value is not shown."""
|
||||||
|
env = {constants.ENV_SENDER_ADDRESS: "me@example.com"}
|
||||||
|
code = main([*data, "--today", "2026-10-17"], environ=env, smtp_factory=FakeSmtp)
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert code == 1
|
||||||
|
assert constants.ENV_SENDER_TOKEN in err
|
||||||
|
assert "me@example.com" not in err
|
||||||
|
assert FakeSmtp.messages == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_birthdays_today_prints_message(
|
||||||
|
data: list[str], capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""A day without birthdays prints a message and succeeds."""
|
||||||
|
code = main([*data, "--today", "2026-01-01", "--dry-run"], environ={})
|
||||||
|
assert code == 0
|
||||||
|
assert "No birthdays today" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_csv_returns_1(
|
||||||
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""A missing CSV file returns 1 with an error message."""
|
||||||
|
code = main(["--csv", str(tmp_path / "none.csv"), "--dry-run"], environ={})
|
||||||
|
assert code == 1
|
||||||
|
assert "error:" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_smtp_failure_returns_1_without_token(
|
||||||
|
data: list[str], capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""A failing login returns 1 and never prints the token."""
|
||||||
|
FakeSmtp.fail = True
|
||||||
|
code = main([*data, "--today", "2026-10-17"], environ=ENV, smtp_factory=FakeSmtp)
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert code == 1
|
||||||
|
assert "tok-123" not in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_shipped_birthdays_csv_has_bob_and_carol_on_oct_17(
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
"""The shipped sample data works with the shipped letters."""
|
||||||
|
args = [
|
||||||
|
"--csv",
|
||||||
|
str(ROOT / "birthdays.csv"),
|
||||||
|
"--letters",
|
||||||
|
str(ROOT / "letter_templates"),
|
||||||
|
"--today",
|
||||||
|
"2026-10-17",
|
||||||
|
"--dry-run",
|
||||||
|
]
|
||||||
|
assert main(args, environ={}) == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "bob@example.com" in out
|
||||||
|
assert "carol@example.com" in out
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Integration tests for CsvBirthdayRepository against real files."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from birthday_wisher.domain.errors import BirthdayDataError
|
||||||
|
from birthday_wisher.domain.person import Person
|
||||||
|
from birthday_wisher.infrastructure.csv_birthday_repository import (
|
||||||
|
CsvBirthdayRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
HEADER = "name,email,year,month,day\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _write(tmp_path: Path, text: str) -> Path:
|
||||||
|
path = tmp_path / "birthdays.csv"
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_all_reads_people_in_file_order(tmp_path: Path) -> None:
|
||||||
|
"""Rows become Person objects in file order."""
|
||||||
|
path = _write(
|
||||||
|
tmp_path,
|
||||||
|
HEADER + "Ann,ann@example.com,1990,3,14\nBob,bob@example.com,1985,10,17\n",
|
||||||
|
)
|
||||||
|
assert CsvBirthdayRepository(path).load_all() == [
|
||||||
|
Person("Ann", "ann@example.com", 1990, 3, 14),
|
||||||
|
Person("Bob", "bob@example.com", 1985, 10, 17),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_all_header_only_gives_empty_list(tmp_path: Path) -> None:
|
||||||
|
"""A file with only a header holds nobody."""
|
||||||
|
assert CsvBirthdayRepository(_write(tmp_path, HEADER)).load_all() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_all_missing_file_raises(tmp_path: Path) -> None:
|
||||||
|
"""A missing file raises BirthdayDataError."""
|
||||||
|
with pytest.raises(BirthdayDataError):
|
||||||
|
CsvBirthdayRepository(tmp_path / "nope.csv").load_all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_all_missing_column_raises(tmp_path: Path) -> None:
|
||||||
|
"""A missing column is named in the error."""
|
||||||
|
path = _write(tmp_path, "name,email,year,month\nAnn,a@example.com,1990,3\n")
|
||||||
|
with pytest.raises(BirthdayDataError, match="day"):
|
||||||
|
CsvBirthdayRepository(path).load_all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_all_non_numeric_value_raises(tmp_path: Path) -> None:
|
||||||
|
"""A non-numeric month raises and names the line."""
|
||||||
|
path = _write(tmp_path, HEADER + "Ann,a@example.com,1990,March,14\n")
|
||||||
|
with pytest.raises(BirthdayDataError, match="line 2"):
|
||||||
|
CsvBirthdayRepository(path).load_all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_all_short_row_raises(tmp_path: Path) -> None:
|
||||||
|
"""A row with too few fields raises BirthdayDataError."""
|
||||||
|
path = _write(tmp_path, HEADER + "Ann,a@example.com,1990\n")
|
||||||
|
with pytest.raises(BirthdayDataError):
|
||||||
|
CsvBirthdayRepository(path).load_all()
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Integration tests for FolderLetterSource against real files."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from birthday_wisher import constants
|
||||||
|
from birthday_wisher.application.use_cases.send_birthday_wishes import build_letter
|
||||||
|
from birthday_wisher.domain.errors import LetterTemplateError
|
||||||
|
from birthday_wisher.infrastructure.letter_templates import FolderLetterSource
|
||||||
|
|
||||||
|
LETTERS_DIR = Path(__file__).resolve().parents[3] / constants.LETTER_TEMPLATES_DIR
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("number", range(1, constants.LETTER_COUNT + 1))
|
||||||
|
def test_every_real_template_can_be_chosen_and_built(number: int) -> None:
|
||||||
|
"""Each shipped template holds the placeholder and is fully replaced."""
|
||||||
|
template = FolderLetterSource(LETTERS_DIR).read_letter(number)
|
||||||
|
assert constants.NAME_PLACEHOLDER in template
|
||||||
|
letter = build_letter(template, "Zelda")
|
||||||
|
assert "Zelda" in letter
|
||||||
|
assert constants.NAME_PLACEHOLDER not in letter
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_letter_reads_file_from_folder(tmp_path: Path) -> None:
|
||||||
|
"""A template file is returned as written."""
|
||||||
|
(tmp_path / "letter_1.txt").write_text("Hi [NAME]", encoding="utf-8")
|
||||||
|
assert FolderLetterSource(tmp_path).read_letter(1) == "Hi [NAME]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_letter_missing_file_raises_template_error(tmp_path: Path) -> None:
|
||||||
|
"""A missing template raises LetterTemplateError."""
|
||||||
|
with pytest.raises(LetterTemplateError):
|
||||||
|
FolderLetterSource(tmp_path).read_letter(1)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Tests for reading the sender credentials from an environment mapping."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from birthday_wisher import constants
|
||||||
|
from birthday_wisher.domain.errors import ConfigurationError
|
||||||
|
from birthday_wisher.infrastructure.settings import Credentials, read_credentials
|
||||||
|
|
||||||
|
ADDRESS = constants.ENV_SENDER_ADDRESS
|
||||||
|
TOKEN = constants.ENV_SENDER_TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_credentials_returns_address_and_token() -> None:
|
||||||
|
"""Both variables present give the credentials."""
|
||||||
|
result = read_credentials({ADDRESS: "me@example.com", TOKEN: "tok"})
|
||||||
|
assert result == Credentials("me@example.com", "tok")
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_credentials_reports_missing_names_not_values() -> None:
|
||||||
|
"""The error names the missing variable and shows no value."""
|
||||||
|
with pytest.raises(ConfigurationError) as info:
|
||||||
|
read_credentials({ADDRESS: "me@example.com"})
|
||||||
|
text = str(info.value)
|
||||||
|
assert TOKEN in text
|
||||||
|
assert "me@example.com" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_credentials_reports_both_when_none_set() -> None:
|
||||||
|
"""Both missing names are listed."""
|
||||||
|
with pytest.raises(ConfigurationError) as info:
|
||||||
|
read_credentials({})
|
||||||
|
assert ADDRESS in str(info.value)
|
||||||
|
assert TOKEN in str(info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_credentials_treats_empty_value_as_missing() -> None:
|
||||||
|
"""An empty value counts as missing."""
|
||||||
|
with pytest.raises(ConfigurationError, match=TOKEN):
|
||||||
|
read_credentials({ADDRESS: "me@example.com", TOKEN: ""})
|
||||||
|
|
||||||
|
|
||||||
|
def test_credentials_repr_hides_token() -> None:
|
||||||
|
"""Neither repr nor str shows the token."""
|
||||||
|
credentials = Credentials("me@example.com", "super-secret")
|
||||||
|
assert "super-secret" not in repr(credentials)
|
||||||
|
assert "super-secret" not in str(credentials)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Tests for SmtpMailer with a fake SMTP connection (no network)."""
|
||||||
|
|
||||||
|
import smtplib
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from types import TracebackType
|
||||||
|
from typing import ClassVar, Self
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from birthday_wisher import constants
|
||||||
|
from birthday_wisher.domain.errors import MailError
|
||||||
|
from birthday_wisher.infrastructure.smtp_mailer import SmtpMailer
|
||||||
|
|
||||||
|
TOKEN = "s3cret-token"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSmtp:
|
||||||
|
"""Records calls in order; can raise on a chosen step."""
|
||||||
|
|
||||||
|
calls: ClassVar[list[str]] = []
|
||||||
|
messages: ClassVar[list[EmailMessage]] = []
|
||||||
|
fail_on: ClassVar[dict[str, Exception]] = {}
|
||||||
|
created_with: ClassVar[list[tuple[str, int]]] = []
|
||||||
|
|
||||||
|
def __init__(self, host: str, port: int) -> None:
|
||||||
|
FakeSmtp.created_with.append((host, port))
|
||||||
|
|
||||||
|
def __enter__(self) -> Self:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(
|
||||||
|
self,
|
||||||
|
exc_type: type[BaseException] | None,
|
||||||
|
exc: BaseException | None,
|
||||||
|
tb: TracebackType | None,
|
||||||
|
) -> None:
|
||||||
|
FakeSmtp.calls.append("quit")
|
||||||
|
|
||||||
|
def _step(self, name: str) -> None:
|
||||||
|
FakeSmtp.calls.append(name)
|
||||||
|
if name in FakeSmtp.fail_on:
|
||||||
|
raise FakeSmtp.fail_on[name]
|
||||||
|
|
||||||
|
def starttls(self) -> None:
|
||||||
|
self._step("starttls")
|
||||||
|
|
||||||
|
def login(self, user: str, password: str) -> None:
|
||||||
|
self._step("login")
|
||||||
|
assert (user, password) == ("me@example.com", TOKEN)
|
||||||
|
|
||||||
|
def send_message(self, message: EmailMessage) -> None:
|
||||||
|
self._step("send_message")
|
||||||
|
FakeSmtp.messages.append(message)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_fake() -> None:
|
||||||
|
FakeSmtp.calls = []
|
||||||
|
FakeSmtp.messages = []
|
||||||
|
FakeSmtp.fail_on = {}
|
||||||
|
FakeSmtp.created_with = []
|
||||||
|
|
||||||
|
|
||||||
|
def _mailer() -> SmtpMailer:
|
||||||
|
return SmtpMailer("smtp.test", 587, "me@example.com", TOKEN, FakeSmtp)
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_calls_starttls_then_login_then_send_message() -> None:
|
||||||
|
"""The SMTP steps run in the required order."""
|
||||||
|
_mailer().send("bob@example.com", constants.EMAIL_SUBJECT, "Hi Bob")
|
||||||
|
assert FakeSmtp.calls == ["starttls", "login", "send_message", "quit"]
|
||||||
|
assert FakeSmtp.created_with == [("smtp.test", 587)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_builds_message_with_subject_recipient_and_body() -> None:
|
||||||
|
"""Subject, recipient, sender and body reach the server."""
|
||||||
|
_mailer().send("bob@example.com", constants.EMAIL_SUBJECT, "Hi Bob")
|
||||||
|
message = FakeSmtp.messages[0]
|
||||||
|
assert message["Subject"] == "Happy Birthday"
|
||||||
|
assert message["To"] == "bob@example.com"
|
||||||
|
assert message["From"] == "me@example.com"
|
||||||
|
assert message.get_content().strip() == "Hi Bob"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("step", ["starttls", "login", "send_message"])
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"error",
|
||||||
|
[
|
||||||
|
smtplib.SMTPAuthenticationError(535, b"bad " + TOKEN.encode()),
|
||||||
|
smtplib.SMTPException("x"),
|
||||||
|
OSError("net down " + TOKEN),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_send_failure_becomes_mail_error_without_token(
|
||||||
|
step: str, error: Exception
|
||||||
|
) -> None:
|
||||||
|
"""smtplib and OS errors become MailError and never carry the token."""
|
||||||
|
FakeSmtp.fail_on = {step: error}
|
||||||
|
with pytest.raises(MailError) as info:
|
||||||
|
_mailer().send("bob@example.com", "Happy Birthday", "Hi")
|
||||||
|
assert TOKEN not in str(info.value)
|
||||||
|
assert "bob@example.com" in str(info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_connection_failure_becomes_mail_error() -> None:
|
||||||
|
"""A refused connection becomes MailError."""
|
||||||
|
|
||||||
|
def refuse(host: str, port: int) -> FakeSmtp:
|
||||||
|
raise ConnectionRefusedError("refused")
|
||||||
|
|
||||||
|
mailer = SmtpMailer("h", 587, "me@example.com", TOKEN, refuse)
|
||||||
|
with pytest.raises(MailError):
|
||||||
|
mailer.send("bob@example.com", "Happy Birthday", "Hi")
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Unit tests for the send-birthday-wishes use case, using fakes of the ports."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from birthday_wisher import constants
|
||||||
|
from birthday_wisher.application.use_cases.send_birthday_wishes import (
|
||||||
|
birthdays_by_day,
|
||||||
|
build_letter,
|
||||||
|
find_birthday_people,
|
||||||
|
send_birthday_wishes,
|
||||||
|
)
|
||||||
|
from birthday_wisher.domain.errors import MailError
|
||||||
|
from birthday_wisher.domain.person import Person
|
||||||
|
|
||||||
|
TODAY = date(2026, 10, 17)
|
||||||
|
ALICE = Person("Alice", "alice@example.com", 1990, 3, 14)
|
||||||
|
BOB = Person("Bob", "bob@example.com", 1985, 10, 17)
|
||||||
|
CAROL = Person("Carol", "carol@example.com", 2001, 10, 17)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRepository:
|
||||||
|
"""In-memory BirthdayRepository."""
|
||||||
|
|
||||||
|
def __init__(self, people: list[Person]) -> None:
|
||||||
|
self._people = people
|
||||||
|
|
||||||
|
def load_all(self) -> list[Person]:
|
||||||
|
return list(self._people)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLetters:
|
||||||
|
"""In-memory LetterSource."""
|
||||||
|
|
||||||
|
def read_letter(self, number: int) -> str:
|
||||||
|
return f"Letter {number} for [NAME]"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMailer:
|
||||||
|
"""Records mails; fails for the addresses in `fail_for`."""
|
||||||
|
|
||||||
|
def __init__(self, fail_for: set[str] | None = None) -> None:
|
||||||
|
self.sent: list[tuple[str, str, str]] = []
|
||||||
|
self._fail_for = fail_for or set()
|
||||||
|
|
||||||
|
def send(self, recipient: str, subject: str, body: str) -> None:
|
||||||
|
if recipient in self._fail_for:
|
||||||
|
raise MailError("boom")
|
||||||
|
self.sent.append((recipient, subject, body))
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_birthday_people_selects_only_today() -> None:
|
||||||
|
assert find_birthday_people([ALICE, BOB], TODAY) == [BOB]
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_birthday_people_returns_both_sharing_a_day() -> None:
|
||||||
|
assert find_birthday_people([BOB, ALICE, CAROL], TODAY) == [BOB, CAROL]
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_birthday_people_empty_when_nobody() -> None:
|
||||||
|
assert find_birthday_people([ALICE], TODAY) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_birthdays_by_day_groups_people_sharing_a_day() -> None:
|
||||||
|
result = birthdays_by_day([ALICE, BOB, CAROL])
|
||||||
|
assert result == {(3, 14): [ALICE], (10, 17): [BOB, CAROL]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_letter_replaces_name() -> None:
|
||||||
|
assert build_letter("Dear [NAME],", "Bob") == "Dear Bob,"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_letter_replaces_every_placeholder() -> None:
|
||||||
|
assert build_letter("[NAME] and [NAME]", "Bo") == "Bo and Bo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_wishes_sends_one_letter_to_todays_person_only() -> None:
|
||||||
|
mailer = FakeMailer()
|
||||||
|
report = send_birthday_wishes(
|
||||||
|
FakeRepository([ALICE, BOB]), FakeLetters(), mailer, TODAY, lambda: 2
|
||||||
|
)
|
||||||
|
assert mailer.sent == [
|
||||||
|
("bob@example.com", constants.EMAIL_SUBJECT, "Letter 2 for Bob")
|
||||||
|
]
|
||||||
|
assert report.sent == ["Bob"]
|
||||||
|
assert report.failed == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_wishes_with_no_birthday_sends_nothing() -> None:
|
||||||
|
mailer = FakeMailer()
|
||||||
|
report = send_birthday_wishes(
|
||||||
|
FakeRepository([ALICE]), FakeLetters(), mailer, TODAY, lambda: 1
|
||||||
|
)
|
||||||
|
assert mailer.sent == []
|
||||||
|
assert report.sent == [] and report.failed == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_wishes_one_failure_does_not_stop_the_others() -> None:
|
||||||
|
mailer = FakeMailer(fail_for={"bob@example.com"})
|
||||||
|
report = send_birthday_wishes(
|
||||||
|
FakeRepository([BOB, CAROL]), FakeLetters(), mailer, TODAY, lambda: 1
|
||||||
|
)
|
||||||
|
assert report.failed == ["Bob"]
|
||||||
|
assert report.sent == ["Carol"]
|
||||||
|
assert [m[0] for m in mailer.sent] == ["carol@example.com"]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Unit tests for the Person entity."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from birthday_wisher.domain.person import Person
|
||||||
|
|
||||||
|
|
||||||
|
def _person(month: int, day: int) -> Person:
|
||||||
|
return Person("Ann", "ann@example.com", 1990, month, day)
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_birthday_on_same_month_and_day() -> None:
|
||||||
|
assert _person(10, 17).has_birthday_on(date(2026, 10, 17))
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_birthday_on_other_day_is_false() -> None:
|
||||||
|
assert not _person(10, 17).has_birthday_on(date(2026, 10, 18))
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_birthday_on_same_day_other_month_is_false() -> None:
|
||||||
|
assert not _person(10, 17).has_birthday_on(date(2026, 11, 17))
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_birthday_on_ignores_year() -> None:
|
||||||
|
assert _person(10, 17).has_birthday_on(date(1999, 10, 17))
|
||||||
|
|
||||||
|
|
||||||
|
def test_leap_day_birthday_is_29_feb_in_leap_year() -> None:
|
||||||
|
assert _person(2, 29).has_birthday_on(date(2024, 2, 29))
|
||||||
|
|
||||||
|
|
||||||
|
def test_leap_day_birthday_is_not_28_feb_in_leap_year() -> None:
|
||||||
|
assert not _person(2, 29).has_birthday_on(date(2024, 2, 28))
|
||||||
|
|
||||||
|
|
||||||
|
def test_leap_day_birthday_is_28_feb_in_non_leap_year() -> None:
|
||||||
|
assert _person(2, 29).has_birthday_on(date(2026, 2, 28))
|
||||||
|
|
||||||
|
|
||||||
|
def test_leap_day_birthday_is_not_1_mar_in_non_leap_year() -> None:
|
||||||
|
assert not _person(2, 29).has_birthday_on(date(2026, 3, 1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_28_feb_birthday_is_28_feb_in_non_leap_year() -> None:
|
||||||
|
assert _person(2, 28).has_birthday_on(date(2026, 2, 28))
|
||||||
Reference in New Issue
Block a user