From 0126093601095f03590622bd4c23635dc6425441 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Sat, 17 Oct 2026 15:37:16 +0800 Subject: [PATCH] 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 --- docs/artifact-registry.md | 2 +- .../sqa/reviews/rc-006-python-code-mil-002.md | 60 +++++++ pyproject.toml | 58 ++++--- src/birthday_wisher/__init__.py | 2 +- src/birthday_wisher/__main__.py | 10 ++ src/birthday_wisher/application/__init__.py | 4 + src/birthday_wisher/application/ports.py | 51 ++++++ .../application/use_cases/__init__.py | 4 + .../use_cases/send_birthday_wishes.py | 91 ++++++++++ src/birthday_wisher/domain/__init__.py | 4 + src/birthday_wisher/domain/errors.py | 34 ++++ src/birthday_wisher/domain/person.py | 46 +++++ .../infrastructure/__init__.py | 4 + .../infrastructure/csv_birthday_repository.py | 54 ++++++ .../infrastructure/letter_templates.py | 34 ++++ .../infrastructure/settings.py | 42 +++++ .../infrastructure/smtp_mailer.py | 59 +++++++ src/birthday_wisher/presentation/__init__.py | 4 + src/birthday_wisher/presentation/cli.py | 127 ++++++++++++++ tests/e2e/presentation/test_cli.py | 158 ++++++++++++++++++ .../test_csv_birthday_repository.py | 63 +++++++ .../infrastructure/test_letter_templates.py | 34 ++++ .../infrastructure/test_settings.py | 46 +++++ .../infrastructure/test_smtp_mailer.py | 113 +++++++++++++ .../use_cases/test_send_birthday_wishes.py | 104 ++++++++++++ tests/unit/domain/test_person.py | 45 +++++ 26 files changed, 1229 insertions(+), 24 deletions(-) create mode 100644 docs/sqa/reviews/rc-006-python-code-mil-002.md create mode 100644 src/birthday_wisher/__main__.py create mode 100644 src/birthday_wisher/application/__init__.py create mode 100644 src/birthday_wisher/application/ports.py create mode 100644 src/birthday_wisher/application/use_cases/__init__.py create mode 100644 src/birthday_wisher/application/use_cases/send_birthday_wishes.py create mode 100644 src/birthday_wisher/domain/__init__.py create mode 100644 src/birthday_wisher/domain/errors.py create mode 100644 src/birthday_wisher/domain/person.py create mode 100644 src/birthday_wisher/infrastructure/__init__.py create mode 100644 src/birthday_wisher/infrastructure/csv_birthday_repository.py create mode 100644 src/birthday_wisher/infrastructure/letter_templates.py create mode 100644 src/birthday_wisher/infrastructure/settings.py create mode 100644 src/birthday_wisher/infrastructure/smtp_mailer.py create mode 100644 src/birthday_wisher/presentation/__init__.py create mode 100644 src/birthday_wisher/presentation/cli.py create mode 100644 tests/e2e/presentation/test_cli.py create mode 100644 tests/integration/infrastructure/test_csv_birthday_repository.py create mode 100644 tests/integration/infrastructure/test_letter_templates.py create mode 100644 tests/integration/infrastructure/test_settings.py create mode 100644 tests/integration/infrastructure/test_smtp_mailer.py create mode 100644 tests/unit/application/use_cases/test_send_birthday_wishes.py create mode 100644 tests/unit/domain/test_person.py diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 92baa30..012a4ff 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -15,7 +15,7 @@ document of a type. `Primary File` may contain a glob (e.g. | SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 | | PP | Project Plan | docs/project-plan.md | 002 | | MIL | Milestone / Gateway | docs/milestones/*.md | 004 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 006 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 007 | ## Languages diff --git a/docs/sqa/reviews/rc-006-python-code-mil-002.md b/docs/sqa/reviews/rc-006-python-code-mil-002.md new file mode 100644 index 0000000..9232894 --- /dev/null +++ b/docs/sqa/reviews/rc-006-python-code-mil-002.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index d9561b0..e880802 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/birthday_wisher/__init__.py b/src/birthday_wisher/__init__.py index 985749c..58ddce5 100644 --- a/src/birthday_wisher/__init__.py +++ b/src/birthday_wisher/__init__.py @@ -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. """ diff --git a/src/birthday_wisher/__main__.py b/src/birthday_wisher/__main__.py new file mode 100644 index 0000000..5b6c14c --- /dev/null +++ b/src/birthday_wisher/__main__.py @@ -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()) diff --git a/src/birthday_wisher/application/__init__.py b/src/birthday_wisher/application/__init__.py new file mode 100644 index 0000000..cebc5e7 --- /dev/null +++ b/src/birthday_wisher/application/__init__.py @@ -0,0 +1,4 @@ +"""! +@file __init__.py +@brief Application layer: the use case and the ports it needs. +""" diff --git a/src/birthday_wisher/application/ports.py b/src/birthday_wisher/application/ports.py new file mode 100644 index 0000000..0c1a7bd --- /dev/null +++ b/src/birthday_wisher/application/ports.py @@ -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. + """ + ... diff --git a/src/birthday_wisher/application/use_cases/__init__.py b/src/birthday_wisher/application/use_cases/__init__.py new file mode 100644 index 0000000..001c539 --- /dev/null +++ b/src/birthday_wisher/application/use_cases/__init__.py @@ -0,0 +1,4 @@ +"""! +@file __init__.py +@brief Use cases of the birthday wisher. +""" diff --git a/src/birthday_wisher/application/use_cases/send_birthday_wishes.py b/src/birthday_wisher/application/use_cases/send_birthday_wishes.py new file mode 100644 index 0000000..428fa7f --- /dev/null +++ b/src/birthday_wisher/application/use_cases/send_birthday_wishes.py @@ -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 diff --git a/src/birthday_wisher/domain/__init__.py b/src/birthday_wisher/domain/__init__.py new file mode 100644 index 0000000..2d4d74d --- /dev/null +++ b/src/birthday_wisher/domain/__init__.py @@ -0,0 +1,4 @@ +"""! +@file __init__.py +@brief Domain layer: the business rules, independent of files, mail and the console. +""" diff --git a/src/birthday_wisher/domain/errors.py b/src/birthday_wisher/domain/errors.py new file mode 100644 index 0000000..e0050fd --- /dev/null +++ b/src/birthday_wisher/domain/errors.py @@ -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. + """ diff --git a/src/birthday_wisher/domain/person.py b/src/birthday_wisher/domain/person.py new file mode 100644 index 0000000..aa0aef6 --- /dev/null +++ b/src/birthday_wisher/domain/person.py @@ -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 diff --git a/src/birthday_wisher/infrastructure/__init__.py b/src/birthday_wisher/infrastructure/__init__.py new file mode 100644 index 0000000..7c282f5 --- /dev/null +++ b/src/birthday_wisher/infrastructure/__init__.py @@ -0,0 +1,4 @@ +"""! +@file __init__.py +@brief Infrastructure layer: files, SMTP and environment settings. +""" diff --git a/src/birthday_wisher/infrastructure/csv_birthday_repository.py b/src/birthday_wisher/infrastructure/csv_birthday_repository.py new file mode 100644 index 0000000..6d31393 --- /dev/null +++ b/src/birthday_wisher/infrastructure/csv_birthday_repository.py @@ -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 diff --git a/src/birthday_wisher/infrastructure/letter_templates.py b/src/birthday_wisher/infrastructure/letter_templates.py new file mode 100644 index 0000000..8bcae59 --- /dev/null +++ b/src/birthday_wisher/infrastructure/letter_templates.py @@ -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 diff --git a/src/birthday_wisher/infrastructure/settings.py b/src/birthday_wisher/infrastructure/settings.py new file mode 100644 index 0000000..653fc11 --- /dev/null +++ b/src/birthday_wisher/infrastructure/settings.py @@ -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]]) diff --git a/src/birthday_wisher/infrastructure/smtp_mailer.py b/src/birthday_wisher/infrastructure/smtp_mailer.py new file mode 100644 index 0000000..9f7480a --- /dev/null +++ b/src/birthday_wisher/infrastructure/smtp_mailer.py @@ -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 diff --git a/src/birthday_wisher/presentation/__init__.py b/src/birthday_wisher/presentation/__init__.py new file mode 100644 index 0000000..d3c40d9 --- /dev/null +++ b/src/birthday_wisher/presentation/__init__.py @@ -0,0 +1,4 @@ +"""! +@file __init__.py +@brief Presentation layer: the command line and the composition root. +""" diff --git a/src/birthday_wisher/presentation/cli.py b/src/birthday_wisher/presentation/cli.py new file mode 100644 index 0000000..c3adf3d --- /dev/null +++ b/src/birthday_wisher/presentation/cli.py @@ -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 diff --git a/tests/e2e/presentation/test_cli.py b/tests/e2e/presentation/test_cli.py new file mode 100644 index 0000000..21ff06d --- /dev/null +++ b/tests/e2e/presentation/test_cli.py @@ -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 diff --git a/tests/integration/infrastructure/test_csv_birthday_repository.py b/tests/integration/infrastructure/test_csv_birthday_repository.py new file mode 100644 index 0000000..0ea7a2d --- /dev/null +++ b/tests/integration/infrastructure/test_csv_birthday_repository.py @@ -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() diff --git a/tests/integration/infrastructure/test_letter_templates.py b/tests/integration/infrastructure/test_letter_templates.py new file mode 100644 index 0000000..d1cdb57 --- /dev/null +++ b/tests/integration/infrastructure/test_letter_templates.py @@ -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) diff --git a/tests/integration/infrastructure/test_settings.py b/tests/integration/infrastructure/test_settings.py new file mode 100644 index 0000000..c5f8363 --- /dev/null +++ b/tests/integration/infrastructure/test_settings.py @@ -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) diff --git a/tests/integration/infrastructure/test_smtp_mailer.py b/tests/integration/infrastructure/test_smtp_mailer.py new file mode 100644 index 0000000..9798c32 --- /dev/null +++ b/tests/integration/infrastructure/test_smtp_mailer.py @@ -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") diff --git a/tests/unit/application/use_cases/test_send_birthday_wishes.py b/tests/unit/application/use_cases/test_send_birthday_wishes.py new file mode 100644 index 0000000..65fc009 --- /dev/null +++ b/tests/unit/application/use_cases/test_send_birthday_wishes.py @@ -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"] diff --git a/tests/unit/domain/test_person.py b/tests/unit/domain/test_person.py new file mode 100644 index 0000000..6b2fb5f --- /dev/null +++ b/tests/unit/domain/test_person.py @@ -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))