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
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Integration tests for CsvBirthdayRepository against real files."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from birthday_wisher.domain.errors import BirthdayDataError
|
||||
from birthday_wisher.domain.person import Person
|
||||
from birthday_wisher.infrastructure.csv_birthday_repository import (
|
||||
CsvBirthdayRepository,
|
||||
)
|
||||
|
||||
HEADER = "name,email,year,month,day\n"
|
||||
|
||||
|
||||
def _write(tmp_path: Path, text: str) -> Path:
|
||||
path = tmp_path / "birthdays.csv"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_load_all_reads_people_in_file_order(tmp_path: Path) -> None:
|
||||
"""Rows become Person objects in file order."""
|
||||
path = _write(
|
||||
tmp_path,
|
||||
HEADER + "Ann,ann@example.com,1990,3,14\nBob,bob@example.com,1985,10,17\n",
|
||||
)
|
||||
assert CsvBirthdayRepository(path).load_all() == [
|
||||
Person("Ann", "ann@example.com", 1990, 3, 14),
|
||||
Person("Bob", "bob@example.com", 1985, 10, 17),
|
||||
]
|
||||
|
||||
|
||||
def test_load_all_header_only_gives_empty_list(tmp_path: Path) -> None:
|
||||
"""A file with only a header holds nobody."""
|
||||
assert CsvBirthdayRepository(_write(tmp_path, HEADER)).load_all() == []
|
||||
|
||||
|
||||
def test_load_all_missing_file_raises(tmp_path: Path) -> None:
|
||||
"""A missing file raises BirthdayDataError."""
|
||||
with pytest.raises(BirthdayDataError):
|
||||
CsvBirthdayRepository(tmp_path / "nope.csv").load_all()
|
||||
|
||||
|
||||
def test_load_all_missing_column_raises(tmp_path: Path) -> None:
|
||||
"""A missing column is named in the error."""
|
||||
path = _write(tmp_path, "name,email,year,month\nAnn,a@example.com,1990,3\n")
|
||||
with pytest.raises(BirthdayDataError, match="day"):
|
||||
CsvBirthdayRepository(path).load_all()
|
||||
|
||||
|
||||
def test_load_all_non_numeric_value_raises(tmp_path: Path) -> None:
|
||||
"""A non-numeric month raises and names the line."""
|
||||
path = _write(tmp_path, HEADER + "Ann,a@example.com,1990,March,14\n")
|
||||
with pytest.raises(BirthdayDataError, match="line 2"):
|
||||
CsvBirthdayRepository(path).load_all()
|
||||
|
||||
|
||||
def test_load_all_short_row_raises(tmp_path: Path) -> None:
|
||||
"""A row with too few fields raises BirthdayDataError."""
|
||||
path = _write(tmp_path, HEADER + "Ann,a@example.com,1990\n")
|
||||
with pytest.raises(BirthdayDataError):
|
||||
CsvBirthdayRepository(path).load_all()
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Integration tests for FolderLetterSource against real files."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from birthday_wisher import constants
|
||||
from birthday_wisher.application.use_cases.send_birthday_wishes import build_letter
|
||||
from birthday_wisher.domain.errors import LetterTemplateError
|
||||
from birthday_wisher.infrastructure.letter_templates import FolderLetterSource
|
||||
|
||||
LETTERS_DIR = Path(__file__).resolve().parents[3] / constants.LETTER_TEMPLATES_DIR
|
||||
|
||||
|
||||
@pytest.mark.parametrize("number", range(1, constants.LETTER_COUNT + 1))
|
||||
def test_every_real_template_can_be_chosen_and_built(number: int) -> None:
|
||||
"""Each shipped template holds the placeholder and is fully replaced."""
|
||||
template = FolderLetterSource(LETTERS_DIR).read_letter(number)
|
||||
assert constants.NAME_PLACEHOLDER in template
|
||||
letter = build_letter(template, "Zelda")
|
||||
assert "Zelda" in letter
|
||||
assert constants.NAME_PLACEHOLDER not in letter
|
||||
|
||||
|
||||
def test_read_letter_reads_file_from_folder(tmp_path: Path) -> None:
|
||||
"""A template file is returned as written."""
|
||||
(tmp_path / "letter_1.txt").write_text("Hi [NAME]", encoding="utf-8")
|
||||
assert FolderLetterSource(tmp_path).read_letter(1) == "Hi [NAME]"
|
||||
|
||||
|
||||
def test_read_letter_missing_file_raises_template_error(tmp_path: Path) -> None:
|
||||
"""A missing template raises LetterTemplateError."""
|
||||
with pytest.raises(LetterTemplateError):
|
||||
FolderLetterSource(tmp_path).read_letter(1)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Tests for reading the sender credentials from an environment mapping."""
|
||||
|
||||
import pytest
|
||||
|
||||
from birthday_wisher import constants
|
||||
from birthday_wisher.domain.errors import ConfigurationError
|
||||
from birthday_wisher.infrastructure.settings import Credentials, read_credentials
|
||||
|
||||
ADDRESS = constants.ENV_SENDER_ADDRESS
|
||||
TOKEN = constants.ENV_SENDER_TOKEN
|
||||
|
||||
|
||||
def test_read_credentials_returns_address_and_token() -> None:
|
||||
"""Both variables present give the credentials."""
|
||||
result = read_credentials({ADDRESS: "me@example.com", TOKEN: "tok"})
|
||||
assert result == Credentials("me@example.com", "tok")
|
||||
|
||||
|
||||
def test_read_credentials_reports_missing_names_not_values() -> None:
|
||||
"""The error names the missing variable and shows no value."""
|
||||
with pytest.raises(ConfigurationError) as info:
|
||||
read_credentials({ADDRESS: "me@example.com"})
|
||||
text = str(info.value)
|
||||
assert TOKEN in text
|
||||
assert "me@example.com" not in text
|
||||
|
||||
|
||||
def test_read_credentials_reports_both_when_none_set() -> None:
|
||||
"""Both missing names are listed."""
|
||||
with pytest.raises(ConfigurationError) as info:
|
||||
read_credentials({})
|
||||
assert ADDRESS in str(info.value)
|
||||
assert TOKEN in str(info.value)
|
||||
|
||||
|
||||
def test_read_credentials_treats_empty_value_as_missing() -> None:
|
||||
"""An empty value counts as missing."""
|
||||
with pytest.raises(ConfigurationError, match=TOKEN):
|
||||
read_credentials({ADDRESS: "me@example.com", TOKEN: ""})
|
||||
|
||||
|
||||
def test_credentials_repr_hides_token() -> None:
|
||||
"""Neither repr nor str shows the token."""
|
||||
credentials = Credentials("me@example.com", "super-secret")
|
||||
assert "super-secret" not in repr(credentials)
|
||||
assert "super-secret" not in str(credentials)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Tests for SmtpMailer with a fake SMTP connection (no network)."""
|
||||
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from types import TracebackType
|
||||
from typing import ClassVar, Self
|
||||
|
||||
import pytest
|
||||
|
||||
from birthday_wisher import constants
|
||||
from birthday_wisher.domain.errors import MailError
|
||||
from birthday_wisher.infrastructure.smtp_mailer import SmtpMailer
|
||||
|
||||
TOKEN = "s3cret-token"
|
||||
|
||||
|
||||
class FakeSmtp:
|
||||
"""Records calls in order; can raise on a chosen step."""
|
||||
|
||||
calls: ClassVar[list[str]] = []
|
||||
messages: ClassVar[list[EmailMessage]] = []
|
||||
fail_on: ClassVar[dict[str, Exception]] = {}
|
||||
created_with: ClassVar[list[tuple[str, int]]] = []
|
||||
|
||||
def __init__(self, host: str, port: int) -> None:
|
||||
FakeSmtp.created_with.append((host, port))
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> None:
|
||||
FakeSmtp.calls.append("quit")
|
||||
|
||||
def _step(self, name: str) -> None:
|
||||
FakeSmtp.calls.append(name)
|
||||
if name in FakeSmtp.fail_on:
|
||||
raise FakeSmtp.fail_on[name]
|
||||
|
||||
def starttls(self) -> None:
|
||||
self._step("starttls")
|
||||
|
||||
def login(self, user: str, password: str) -> None:
|
||||
self._step("login")
|
||||
assert (user, password) == ("me@example.com", TOKEN)
|
||||
|
||||
def send_message(self, message: EmailMessage) -> None:
|
||||
self._step("send_message")
|
||||
FakeSmtp.messages.append(message)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_fake() -> None:
|
||||
FakeSmtp.calls = []
|
||||
FakeSmtp.messages = []
|
||||
FakeSmtp.fail_on = {}
|
||||
FakeSmtp.created_with = []
|
||||
|
||||
|
||||
def _mailer() -> SmtpMailer:
|
||||
return SmtpMailer("smtp.test", 587, "me@example.com", TOKEN, FakeSmtp)
|
||||
|
||||
|
||||
def test_send_calls_starttls_then_login_then_send_message() -> None:
|
||||
"""The SMTP steps run in the required order."""
|
||||
_mailer().send("bob@example.com", constants.EMAIL_SUBJECT, "Hi Bob")
|
||||
assert FakeSmtp.calls == ["starttls", "login", "send_message", "quit"]
|
||||
assert FakeSmtp.created_with == [("smtp.test", 587)]
|
||||
|
||||
|
||||
def test_send_builds_message_with_subject_recipient_and_body() -> None:
|
||||
"""Subject, recipient, sender and body reach the server."""
|
||||
_mailer().send("bob@example.com", constants.EMAIL_SUBJECT, "Hi Bob")
|
||||
message = FakeSmtp.messages[0]
|
||||
assert message["Subject"] == "Happy Birthday"
|
||||
assert message["To"] == "bob@example.com"
|
||||
assert message["From"] == "me@example.com"
|
||||
assert message.get_content().strip() == "Hi Bob"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("step", ["starttls", "login", "send_message"])
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
smtplib.SMTPAuthenticationError(535, b"bad " + TOKEN.encode()),
|
||||
smtplib.SMTPException("x"),
|
||||
OSError("net down " + TOKEN),
|
||||
],
|
||||
)
|
||||
def test_send_failure_becomes_mail_error_without_token(
|
||||
step: str, error: Exception
|
||||
) -> None:
|
||||
"""smtplib and OS errors become MailError and never carry the token."""
|
||||
FakeSmtp.fail_on = {step: error}
|
||||
with pytest.raises(MailError) as info:
|
||||
_mailer().send("bob@example.com", "Happy Birthday", "Hi")
|
||||
assert TOKEN not in str(info.value)
|
||||
assert "bob@example.com" in str(info.value)
|
||||
|
||||
|
||||
def test_send_connection_failure_becomes_mail_error() -> None:
|
||||
"""A refused connection becomes MailError."""
|
||||
|
||||
def refuse(host: str, port: int) -> FakeSmtp:
|
||||
raise ConnectionRefusedError("refused")
|
||||
|
||||
mailer = SmtpMailer("h", 587, "me@example.com", TOKEN, refuse)
|
||||
with pytest.raises(MailError):
|
||||
mailer.send("bob@example.com", "Happy Birthday", "Hi")
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Unit tests for the send-birthday-wishes use case, using fakes of the ports."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from birthday_wisher import constants
|
||||
from birthday_wisher.application.use_cases.send_birthday_wishes import (
|
||||
birthdays_by_day,
|
||||
build_letter,
|
||||
find_birthday_people,
|
||||
send_birthday_wishes,
|
||||
)
|
||||
from birthday_wisher.domain.errors import MailError
|
||||
from birthday_wisher.domain.person import Person
|
||||
|
||||
TODAY = date(2026, 10, 17)
|
||||
ALICE = Person("Alice", "alice@example.com", 1990, 3, 14)
|
||||
BOB = Person("Bob", "bob@example.com", 1985, 10, 17)
|
||||
CAROL = Person("Carol", "carol@example.com", 2001, 10, 17)
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
"""In-memory BirthdayRepository."""
|
||||
|
||||
def __init__(self, people: list[Person]) -> None:
|
||||
self._people = people
|
||||
|
||||
def load_all(self) -> list[Person]:
|
||||
return list(self._people)
|
||||
|
||||
|
||||
class FakeLetters:
|
||||
"""In-memory LetterSource."""
|
||||
|
||||
def read_letter(self, number: int) -> str:
|
||||
return f"Letter {number} for [NAME]"
|
||||
|
||||
|
||||
class FakeMailer:
|
||||
"""Records mails; fails for the addresses in `fail_for`."""
|
||||
|
||||
def __init__(self, fail_for: set[str] | None = None) -> None:
|
||||
self.sent: list[tuple[str, str, str]] = []
|
||||
self._fail_for = fail_for or set()
|
||||
|
||||
def send(self, recipient: str, subject: str, body: str) -> None:
|
||||
if recipient in self._fail_for:
|
||||
raise MailError("boom")
|
||||
self.sent.append((recipient, subject, body))
|
||||
|
||||
|
||||
def test_find_birthday_people_selects_only_today() -> None:
|
||||
assert find_birthday_people([ALICE, BOB], TODAY) == [BOB]
|
||||
|
||||
|
||||
def test_find_birthday_people_returns_both_sharing_a_day() -> None:
|
||||
assert find_birthday_people([BOB, ALICE, CAROL], TODAY) == [BOB, CAROL]
|
||||
|
||||
|
||||
def test_find_birthday_people_empty_when_nobody() -> None:
|
||||
assert find_birthday_people([ALICE], TODAY) == []
|
||||
|
||||
|
||||
def test_birthdays_by_day_groups_people_sharing_a_day() -> None:
|
||||
result = birthdays_by_day([ALICE, BOB, CAROL])
|
||||
assert result == {(3, 14): [ALICE], (10, 17): [BOB, CAROL]}
|
||||
|
||||
|
||||
def test_build_letter_replaces_name() -> None:
|
||||
assert build_letter("Dear [NAME],", "Bob") == "Dear Bob,"
|
||||
|
||||
|
||||
def test_build_letter_replaces_every_placeholder() -> None:
|
||||
assert build_letter("[NAME] and [NAME]", "Bo") == "Bo and Bo"
|
||||
|
||||
|
||||
def test_send_wishes_sends_one_letter_to_todays_person_only() -> None:
|
||||
mailer = FakeMailer()
|
||||
report = send_birthday_wishes(
|
||||
FakeRepository([ALICE, BOB]), FakeLetters(), mailer, TODAY, lambda: 2
|
||||
)
|
||||
assert mailer.sent == [
|
||||
("bob@example.com", constants.EMAIL_SUBJECT, "Letter 2 for Bob")
|
||||
]
|
||||
assert report.sent == ["Bob"]
|
||||
assert report.failed == []
|
||||
|
||||
|
||||
def test_send_wishes_with_no_birthday_sends_nothing() -> None:
|
||||
mailer = FakeMailer()
|
||||
report = send_birthday_wishes(
|
||||
FakeRepository([ALICE]), FakeLetters(), mailer, TODAY, lambda: 1
|
||||
)
|
||||
assert mailer.sent == []
|
||||
assert report.sent == [] and report.failed == []
|
||||
|
||||
|
||||
def test_send_wishes_one_failure_does_not_stop_the_others() -> None:
|
||||
mailer = FakeMailer(fail_for={"bob@example.com"})
|
||||
report = send_birthday_wishes(
|
||||
FakeRepository([BOB, CAROL]), FakeLetters(), mailer, TODAY, lambda: 1
|
||||
)
|
||||
assert report.failed == ["Bob"]
|
||||
assert report.sent == ["Carol"]
|
||||
assert [m[0] for m in mailer.sent] == ["carol@example.com"]
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Unit tests for the Person entity."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from birthday_wisher.domain.person import Person
|
||||
|
||||
|
||||
def _person(month: int, day: int) -> Person:
|
||||
return Person("Ann", "ann@example.com", 1990, month, day)
|
||||
|
||||
|
||||
def test_has_birthday_on_same_month_and_day() -> None:
|
||||
assert _person(10, 17).has_birthday_on(date(2026, 10, 17))
|
||||
|
||||
|
||||
def test_has_birthday_on_other_day_is_false() -> None:
|
||||
assert not _person(10, 17).has_birthday_on(date(2026, 10, 18))
|
||||
|
||||
|
||||
def test_has_birthday_on_same_day_other_month_is_false() -> None:
|
||||
assert not _person(10, 17).has_birthday_on(date(2026, 11, 17))
|
||||
|
||||
|
||||
def test_has_birthday_on_ignores_year() -> None:
|
||||
assert _person(10, 17).has_birthday_on(date(1999, 10, 17))
|
||||
|
||||
|
||||
def test_leap_day_birthday_is_29_feb_in_leap_year() -> None:
|
||||
assert _person(2, 29).has_birthday_on(date(2024, 2, 29))
|
||||
|
||||
|
||||
def test_leap_day_birthday_is_not_28_feb_in_leap_year() -> None:
|
||||
assert not _person(2, 29).has_birthday_on(date(2024, 2, 28))
|
||||
|
||||
|
||||
def test_leap_day_birthday_is_28_feb_in_non_leap_year() -> None:
|
||||
assert _person(2, 29).has_birthday_on(date(2026, 2, 28))
|
||||
|
||||
|
||||
def test_leap_day_birthday_is_not_1_mar_in_non_leap_year() -> None:
|
||||
assert not _person(2, 29).has_birthday_on(date(2026, 3, 1))
|
||||
|
||||
|
||||
def test_28_feb_birthday_is_28_feb_in_non_leap_year() -> None:
|
||||
assert _person(2, 28).has_birthday_on(date(2026, 2, 28))
|
||||
Reference in New Issue
Block a user