Implement the birthday wisher core (MIL-002)

Layered package (domain, application, infrastructure, presentation), CLI with
--dry-run and --today, SMTP with STARTTLS behind a port, 56 offline tests and
the Python code review RC-006.

Task: MIL-002#1
Task: MIL-002#2
Task: MIL-002#3
Task: MIL-002#4
Task: MIL-002#5
Task: MIL-002#6
Refs #7
Refs #8
Refs #9
Refs #10
Refs #11
Refs #12
This commit is contained in:
2026-10-17 15:37:16 +08:00
parent 70b2b3c02f
commit 0126093601
26 changed files with 1229 additions and 24 deletions
@@ -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")