Merge pull request 'MIL-002: birthday wisher core' (#20) from mil-002-birthday-wisher-core into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s

This commit was merged in pull request #20.
This commit is contained in:
2026-10-17 09:37:35 +02:00
26 changed files with 1229 additions and 24 deletions
+1 -1
View File
@@ -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 | 004 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 006 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 007 |
## 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
View File
@@ -1,22 +1,36 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "birthday-wisher"
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)."
readme = "README.md"
requires-python = ">=3.13"
license = { file = "LICENSE" }
dependencies = []
[project.optional-dependencies]
dev = ["pytest>=8"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "birthday-wisher"
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)."
readme = "README.md"
requires-python = ">=3.13"
license = { file = "LICENSE" }
dependencies = []
[project.scripts]
birthday-wisher = "birthday_wisher.presentation.cli:main"
[project.optional-dependencies]
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11"]
[tool.setuptools.packages.find]
where = ["src"]
[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 -1
View File
@@ -1,4 +1,4 @@
"""!
@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.
"""
+10
View File
@@ -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.
"""
+51
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
"""!
@file __init__.py
@brief Domain layer: the business rules, independent of files, mail and the console.
"""
+34
View File
@@ -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.
"""
+46
View File
@@ -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.
"""
+127
View File
@@ -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
+158
View File
@@ -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"]
+45
View File
@@ -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))