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:
@@ -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
|
||||
Reference in New Issue
Block a user