Add comprehensive test coverage for 8 modules

Increases overall test coverage from 53% to 56% by adding tests for:
- accommodation.py (60% → 100%)
- birthday.py (24% → 100%)
- calendar.py (19% → 100%)
- carnival.py (33% → 100%)
- domains.py (75% → 100%)
- events_yaml.py (50% → 96%)
- fx.py (14% → 21% for tested functions)
- sun.py (55% → 100%)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2025-07-20 11:39:49 +02:00
parent 8db777ae8b
commit e6327780aa
8 changed files with 1390 additions and 0 deletions

186
tests/test_accommodation.py Normal file
View file

@ -0,0 +1,186 @@
"""Tests for accommodation functionality."""
import tempfile
from datetime import date, datetime
from typing import Any
import pytest
import yaml
from agenda.accommodation import get_events
from agenda.event import Event
class TestGetEvents:
"""Test the get_events function."""
def test_get_events_airbnb(self) -> None:
"""Test getting accommodation events for Airbnb."""
accommodation_data = [
{
"from": date(2024, 6, 1),
"to": date(2024, 6, 5),
"location": "Paris",
"operator": "airbnb",
"url": "https://airbnb.com/rooms/123"
}
]
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(accommodation_data, f)
filepath = f.name
try:
events = get_events(filepath)
assert len(events) == 1
event = events[0]
assert event.date == date(2024, 6, 1)
assert event.end_date == date(2024, 6, 5)
assert event.name == "accommodation"
assert event.title == "Paris Airbnb"
assert event.url == "https://airbnb.com/rooms/123"
finally:
import os
os.unlink(filepath)
def test_get_events_hotel(self) -> None:
"""Test getting accommodation events for hotel."""
accommodation_data = [
{
"from": date(2024, 6, 1),
"to": date(2024, 6, 5),
"name": "Hilton Hotel",
"url": "https://hilton.com"
}
]
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(accommodation_data, f)
filepath = f.name
try:
events = get_events(filepath)
assert len(events) == 1
event = events[0]
assert event.date == date(2024, 6, 1)
assert event.end_date == date(2024, 6, 5)
assert event.name == "accommodation"
assert event.title == "Hilton Hotel"
assert event.url == "https://hilton.com"
finally:
import os
os.unlink(filepath)
def test_get_events_no_url(self) -> None:
"""Test getting accommodation events without URL."""
accommodation_data = [
{
"from": date(2024, 6, 1),
"to": date(2024, 6, 5),
"name": "Local B&B"
}
]
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(accommodation_data, f)
filepath = f.name
try:
events = get_events(filepath)
assert len(events) == 1
event = events[0]
assert event.url is None
assert event.title == "Local B&B"
finally:
import os
os.unlink(filepath)
def test_get_events_multiple_accommodations(self) -> None:
"""Test getting multiple accommodation events."""
accommodation_data = [
{
"from": date(2024, 6, 1),
"to": date(2024, 6, 5),
"location": "London",
"operator": "airbnb"
},
{
"from": date(2024, 6, 10),
"to": date(2024, 6, 15),
"name": "Royal Hotel"
}
]
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(accommodation_data, f)
filepath = f.name
try:
events = get_events(filepath)
assert len(events) == 2
# Check first accommodation (Airbnb)
assert events[0].title == "London Airbnb"
assert events[0].date == date(2024, 6, 1)
# Check second accommodation (Hotel)
assert events[1].title == "Royal Hotel"
assert events[1].date == date(2024, 6, 10)
finally:
import os
os.unlink(filepath)
def test_get_events_empty_file(self) -> None:
"""Test getting events from empty file."""
accommodation_data: list[dict[str, Any]] = []
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(accommodation_data, f)
filepath = f.name
try:
events = get_events(filepath)
assert events == []
finally:
import os
os.unlink(filepath)
def test_get_events_file_not_found(self) -> None:
"""Test error handling when file doesn't exist."""
with pytest.raises(FileNotFoundError):
get_events("/nonexistent/file.yaml")
def test_get_events_datetime_objects(self) -> None:
"""Test with datetime objects instead of dates."""
accommodation_data = [
{
"from": datetime(2024, 6, 1, 15, 0),
"to": datetime(2024, 6, 5, 11, 0),
"name": "Hotel Example"
}
]
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(accommodation_data, f)
filepath = f.name
try:
events = get_events(filepath)
assert len(events) == 1
event = events[0]
assert event.date == datetime(2024, 6, 1, 15, 0)
assert event.end_date == datetime(2024, 6, 5, 11, 0)
finally:
import os
os.unlink(filepath)

227
tests/test_birthday.py Normal file
View file

@ -0,0 +1,227 @@
"""Tests for birthday functionality."""
import tempfile
from datetime import date
from typing import Any
import pytest
import yaml
from agenda.birthday import YEAR_NOT_KNOWN, get_birthdays, next_birthday
from agenda.event import Event
class TestNextBirthday:
"""Test the next_birthday function."""
def test_birthday_this_year_future(self) -> None:
"""Test birthday that hasn't occurred this year."""
from_date = date(2024, 3, 15)
birth_date = date(1990, 6, 20)
next_bday, age = next_birthday(from_date, birth_date)
assert next_bday == date(2024, 6, 20)
assert age == 34
def test_birthday_this_year_past(self) -> None:
"""Test birthday that already occurred this year."""
from_date = date(2024, 8, 15)
birth_date = date(1990, 6, 20)
next_bday, age = next_birthday(from_date, birth_date)
assert next_bday == date(2025, 6, 20)
assert age == 35
def test_birthday_today(self) -> None:
"""Test when today is the birthday."""
from_date = date(2024, 6, 20)
birth_date = date(1990, 6, 20)
next_bday, age = next_birthday(from_date, birth_date)
assert next_bday == date(2024, 6, 20)
assert age == 34
def test_birthday_unknown_year(self) -> None:
"""Test birthday with unknown year."""
from_date = date(2024, 3, 15)
birth_date = date(YEAR_NOT_KNOWN, 6, 20)
next_bday, age = next_birthday(from_date, birth_date)
assert next_bday == date(2024, 6, 20)
assert age is None
def test_birthday_unknown_year_past(self) -> None:
"""Test birthday with unknown year that already passed."""
from_date = date(2024, 8, 15)
birth_date = date(YEAR_NOT_KNOWN, 6, 20)
next_bday, age = next_birthday(from_date, birth_date)
assert next_bday == date(2025, 6, 20)
assert age is None
def test_leap_year_birthday(self) -> None:
"""Test birthday on leap day."""
from_date = date(2024, 1, 15) # 2024 is a leap year
birth_date = date(2000, 2, 29) # Born on leap day
next_bday, age = next_birthday(from_date, birth_date)
assert next_bday == date(2024, 2, 29)
assert age == 24
class TestGetBirthdays:
"""Test the get_birthdays function."""
def test_get_birthdays_with_year(self) -> None:
"""Test getting birthdays with known birth years."""
birthday_data = [
{
"label": "John Doe",
"birthday": {"year": 1990, "month": 6, "day": 20}
},
{
"label": "Jane Smith",
"birthday": {"year": 1985, "month": 12, "day": 15}
}
]
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(birthday_data, f)
filepath = f.name
try:
from_date = date(2024, 3, 15)
events = get_birthdays(from_date, filepath)
# Should have 4 events (2 people × 2 years each)
assert len(events) == 4
# Check John's birthdays
john_events = [e for e in events if "John Doe" in e.title]
assert len(john_events) == 2
assert john_events[0].date == date(2024, 6, 20)
assert "aged 34" in john_events[0].title
assert john_events[1].date == date(2025, 6, 20)
assert "aged 35" in john_events[1].title
# Check Jane's birthdays
jane_events = [e for e in events if "Jane Smith" in e.title]
assert len(jane_events) == 2
assert jane_events[0].date == date(2024, 12, 15)
assert "aged 39" in jane_events[0].title
# All events should be birthday events
for event in events:
assert event.name == "birthday"
assert isinstance(event, Event)
finally:
import os
os.unlink(filepath)
def test_get_birthdays_without_year(self) -> None:
"""Test getting birthdays with unknown birth years."""
birthday_data = [
{
"label": "Anonymous Person",
"birthday": {"month": 6, "day": 20}
}
]
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(birthday_data, f)
filepath = f.name
try:
from_date = date(2024, 3, 15)
events = get_birthdays(from_date, filepath)
# Should have 2 events (1 person × 2 years)
assert len(events) == 2
# Check that age is unknown
for event in events:
assert "age unknown" in event.title
assert "Anonymous Person" in event.title
assert event.name == "birthday"
finally:
import os
os.unlink(filepath)
def test_get_birthdays_mixed_data(self) -> None:
"""Test getting birthdays with mixed known/unknown years."""
birthday_data = [
{
"label": "Known Person",
"birthday": {"year": 1990, "month": 6, "day": 20}
},
{
"label": "Unknown Person",
"birthday": {"month": 8, "day": 15}
}
]
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(birthday_data, f)
filepath = f.name
try:
from_date = date(2024, 3, 15)
events = get_birthdays(from_date, filepath)
# Should have 4 events total
assert len(events) == 4
# Check known person events
known_events = [e for e in events if "Known Person" in e.title]
assert len(known_events) == 2
assert all("aged" in e.title and "age unknown" not in e.title for e in known_events)
# Check unknown person events
unknown_events = [e for e in events if "Unknown Person" in e.title]
assert len(unknown_events) == 2
assert all("age unknown" in e.title for e in unknown_events)
finally:
import os
os.unlink(filepath)
def test_get_birthdays_empty_file(self) -> None:
"""Test getting birthdays from empty file."""
birthday_data: list[dict[str, Any]] = []
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
yaml.dump(birthday_data, f)
filepath = f.name
try:
from_date = date(2024, 3, 15)
events = get_birthdays(from_date, filepath)
assert events == []
finally:
import os
os.unlink(filepath)
def test_get_birthdays_file_not_found(self) -> None:
"""Test error handling when file doesn't exist."""
from_date = date(2024, 3, 15)
with pytest.raises(FileNotFoundError):
get_birthdays(from_date, "/nonexistent/file.yaml")
class TestConstants:
"""Test module constants."""
def test_year_not_known_constant(self) -> None:
"""Test that YEAR_NOT_KNOWN has expected value."""
assert YEAR_NOT_KNOWN == 1900

270
tests/test_calendar.py Normal file
View file

@ -0,0 +1,270 @@
"""Tests for calendar functionality."""
from datetime import date, datetime, timedelta
from typing import Any
from agenda.calendar import build_events, colors, event_type_color_map
from agenda.event import Event
class TestEventTypeColorMap:
"""Test the event type color mapping."""
def test_event_type_color_map_contains_expected_types(self) -> None:
"""Test that color map contains expected event types."""
expected_types = {
"bank_holiday", "conference", "us_holiday",
"birthday", "waste_schedule"
}
assert set(event_type_color_map.keys()) == expected_types
def test_event_type_color_map_values_are_valid(self) -> None:
"""Test that all color values are valid color names."""
expected_colors = {
"success-subtle", "primary-subtle", "secondary-subtle",
"info-subtle", "danger-subtle"
}
assert set(event_type_color_map.values()).issubset(expected_colors)
class TestColorsDict:
"""Test the colors dictionary."""
def test_colors_contains_expected_keys(self) -> None:
"""Test that colors dict contains expected color keys."""
expected_keys = {
"primary-subtle", "secondary-subtle", "success-subtle",
"info-subtle", "warning-subtle", "danger-subtle"
}
assert set(colors.keys()) == expected_keys
def test_colors_values_are_hex_codes(self) -> None:
"""Test that all color values are valid hex codes."""
for color_value in colors.values():
assert color_value.startswith("#")
assert len(color_value) == 7
# Check that remaining characters are valid hex
hex_part = color_value[1:]
int(hex_part, 16) # This will raise ValueError if not valid hex
class TestBuildEvents:
"""Test the build_events function."""
def test_build_events_empty_list(self) -> None:
"""Test building events with empty list."""
result = build_events([])
assert result == []
def test_build_events_today_event_filtered(self) -> None:
"""Test that 'today' events are filtered out."""
events = [
Event(date=date(2024, 1, 1), name="today", title="Today"),
Event(date=date(2024, 1, 2), name="birthday", title="Birthday"),
]
result = build_events(events)
assert len(result) == 1
assert result[0]["title"] == "🎈 Birthday"
def test_build_events_simple_event(self) -> None:
"""Test building a simple event without time."""
events = [
Event(
date=date(2024, 1, 15),
name="birthday",
title="John's Birthday"
)
]
result = build_events(events)
assert len(result) == 1
item = result[0]
assert item["allDay"] is True
assert item["title"] == "🎈 John's Birthday"
assert item["start"] == "2024-01-15"
assert item["end"] == "2024-01-16" # Next day
assert item["color"] == colors["info-subtle"]
assert item["textColor"] == "black"
def test_build_events_with_time(self) -> None:
"""Test building an event with time."""
event_datetime = datetime(2024, 1, 15, 14, 30)
events = [
Event(
date=event_datetime,
name="meeting",
title="Team Meeting"
)
]
result = build_events(events)
assert len(result) == 1
item = result[0]
assert item["allDay"] is False
assert item["title"] == "Team Meeting"
assert item["start"] == "2024-01-15T14:30:00"
assert item["end"] == "2024-01-15T15:00:00" # 30 minutes later
def test_build_events_with_end_date(self) -> None:
"""Test building an event with end date."""
events = [
Event(
date=date(2024, 1, 15),
name="conference",
title="Tech Conference",
end_date=date(2024, 1, 17)
)
]
result = build_events(events)
assert len(result) == 1
item = result[0]
assert item["allDay"] is True
assert item["start"] == "2024-01-15"
assert item["end"] == "2024-01-18" # End date + 1 day
def test_build_events_with_time_and_end_date(self) -> None:
"""Test building an event with time and end date."""
start_datetime = datetime(2024, 1, 15, 9, 0)
end_datetime = datetime(2024, 1, 15, 17, 0)
events = [
Event(
date=start_datetime,
name="workshop",
title="Python Workshop",
end_date=end_datetime
)
]
result = build_events(events)
assert len(result) == 1
item = result[0]
assert item["allDay"] is False
assert item["start"] == "2024-01-15T09:00:00"
assert item["end"] == "2024-01-15T17:00:00"
def test_build_events_with_url(self) -> None:
"""Test building an event with URL."""
events = [
Event(
date=date(2024, 1, 15),
name="conference",
title="Tech Conference",
url="https://example.com"
)
]
result = build_events(events)
assert len(result) == 1
item = result[0]
assert item["url"] == "https://example.com"
def test_build_events_accommodation(self) -> None:
"""Test building accommodation events."""
events = [
Event(
date=datetime(2024, 1, 15, 15, 0), # Check-in time
name="accommodation",
title="Hotel Stay",
end_date=datetime(2024, 1, 17, 11, 0), # Check-out time
url="https://hotel.com"
)
]
result = build_events(events)
# Should create 3 events: main accommodation + check-in + check-out
assert len(result) == 3
# Main accommodation event
main_event = result[0]
assert main_event["allDay"] is True
assert main_event["title"] == "🏨 Hotel Stay"
assert main_event["start"] == "2024-01-15"
assert main_event["end"] == "2024-01-18" # End date + 1 day
assert main_event["url"] == "https://hotel.com"
# Check-in event
checkin_event = result[1]
assert checkin_event["allDay"] is False
assert checkin_event["title"] == "check-in: Hotel Stay"
assert checkin_event["start"] == "2024-01-15T15:00:00"
assert checkin_event["url"] == "https://hotel.com"
# Check-out event
checkout_event = result[2]
assert checkout_event["allDay"] is False
assert checkout_event["title"] == "checkout: Hotel Stay"
assert checkout_event["start"] == "2024-01-17T11:00:00"
assert checkout_event["url"] == "https://hotel.com"
def test_build_events_no_color_for_unknown_type(self) -> None:
"""Test that events with unknown types don't get color."""
events = [
Event(
date=date(2024, 1, 15),
name="unknown_type",
title="Unknown Event"
)
]
result = build_events(events)
assert len(result) == 1
item = result[0]
assert "color" not in item
assert "textColor" not in item
def test_build_events_multiple_event_types(self) -> None:
"""Test building events with different types and colors."""
events = [
Event(date=date(2024, 1, 15), name="bank_holiday", title="New Year"),
Event(date=date(2024, 1, 16), name="conference", title="PyCon"),
Event(date=date(2024, 1, 17), name="birthday", title="Birthday"),
Event(date=date(2024, 1, 18), name="waste_schedule", title="Recycling"),
Event(date=date(2024, 1, 19), name="us_holiday", title="MLK Day"),
]
result = build_events(events)
assert len(result) == 5
# Check colors are assigned correctly
assert result[0]["color"] == colors["success-subtle"] # bank_holiday
assert result[1]["color"] == colors["primary-subtle"] # conference
assert result[2]["color"] == colors["info-subtle"] # birthday
assert result[3]["color"] == colors["danger-subtle"] # waste_schedule
assert result[4]["color"] == colors["secondary-subtle"] # us_holiday
# All should have black text
for item in result:
assert item["textColor"] == "black"
def test_build_events_mixed_scenarios(self) -> None:
"""Test building events with mixed scenarios."""
events = [
# Today event (should be filtered)
Event(date=date(2024, 1, 1), name="today", title="Today"),
# Simple event
Event(date=date(2024, 1, 15), name="birthday", title="Birthday"),
# Event with time
Event(date=datetime(2024, 1, 16, 10, 0), name="meeting", title="Meeting"),
# Accommodation
Event(
date=datetime(2024, 1, 20, 15, 0),
name="accommodation",
title="Hotel",
end_date=datetime(2024, 1, 22, 11, 0),
url="https://hotel.com"
),
]
result = build_events(events)
# Should have 5 events total (today filtered, accommodation creates 3)
assert len(result) == 5
# Verify titles
titles = [item["title"] for item in result]
assert "🎈 Birthday" in titles
assert "Meeting" in titles
assert "🏨 Hotel" in titles
assert "check-in: Hotel" in titles
assert "checkout: Hotel" in titles

125
tests/test_carnival.py Normal file
View file

@ -0,0 +1,125 @@
"""Tests for carnival functionality."""
from datetime import date
from agenda.carnival import rio_carnival_events
from agenda.event import Event
class TestRioCarnivalEvents:
"""Test the rio_carnival_events function."""
def test_carnival_events_single_year(self) -> None:
"""Test getting carnival events for a single year."""
# 2024 Easter is March 31, so carnival should be around Feb 9-14
start_date = date(2024, 1, 1)
end_date = date(2024, 12, 31)
events = rio_carnival_events(start_date, end_date)
assert len(events) == 1
event = events[0]
assert event.name == "carnival"
assert event.title == "Rio Carnival"
assert event.url == "https://en.wikipedia.org/wiki/Rio_Carnival"
assert event.date.year == 2024
assert event.end_date is not None
assert event.end_date.year == 2024
# Should be about 51 days before Easter (around early-mid February)
assert event.date.month == 2
assert event.end_date.month == 2
def test_carnival_events_multiple_years(self) -> None:
"""Test getting carnival events for multiple years."""
start_date = date(2023, 1, 1)
end_date = date(2025, 12, 31)
events = rio_carnival_events(start_date, end_date)
# Should have carnival for 2023, 2024, and 2025
assert len(events) == 3
years = [event.date.year for event in events]
assert sorted(years) == [2023, 2024, 2025]
# All events should be carnival events
for event in events:
assert event.name == "carnival"
assert event.title == "Rio Carnival"
assert event.url == "https://en.wikipedia.org/wiki/Rio_Carnival"
def test_carnival_events_no_overlap(self) -> None:
"""Test when date range doesn't overlap with carnival."""
# Choose a range that's unlikely to include carnival (summer)
start_date = date(2024, 6, 1)
end_date = date(2024, 8, 31)
events = rio_carnival_events(start_date, end_date)
assert events == []
def test_carnival_events_partial_overlap_start(self) -> None:
"""Test when carnival start overlaps with date range."""
# 2024 carnival should be around Feb 9-14
start_date = date(2024, 2, 10) # Might overlap with carnival start
end_date = date(2024, 2, 15)
events = rio_carnival_events(start_date, end_date)
# Should include carnival if there's any overlap
if events:
assert len(events) == 1
assert events[0].name == "carnival"
def test_carnival_events_partial_overlap_end(self) -> None:
"""Test when carnival end overlaps with date range."""
# 2024 carnival should be around Feb 9-14
start_date = date(2024, 2, 12)
end_date = date(2024, 2, 20) # Might overlap with carnival end
events = rio_carnival_events(start_date, end_date)
# Should include carnival if there's any overlap
if events:
assert len(events) == 1
assert events[0].name == "carnival"
def test_carnival_dates_relative_to_easter(self) -> None:
"""Test that carnival dates are correctly calculated relative to Easter."""
start_date = date(2024, 1, 1)
end_date = date(2024, 12, 31)
events = rio_carnival_events(start_date, end_date)
assert len(events) == 1
event = events[0]
# Carnival should be 5 days long
duration = (event.end_date - event.date).days + 1
assert duration == 6 # 51 days before to 46 days before Easter (6 days total)
# Both dates should be in February for 2024
assert event.date.month == 2
assert event.end_date.month == 2
# End date should be after start date
assert event.end_date > event.date
def test_carnival_events_empty_date_range(self) -> None:
"""Test with empty date range."""
start_date = date(2024, 6, 15)
end_date = date(2024, 6, 10) # End before start
events = rio_carnival_events(start_date, end_date)
# Should return empty list for invalid range
assert events == []
def test_carnival_events_same_start_end_date(self) -> None:
"""Test with same start and end date."""
# Pick a date that's definitely not carnival
test_date = date(2024, 7, 15)
events = rio_carnival_events(test_date, test_date)
assert events == []

168
tests/test_domains.py Normal file
View file

@ -0,0 +1,168 @@
"""Tests for domain renewal functionality."""
import csv
import os
import tempfile
from datetime import date
from typing import Any
import pytest
from agenda.domains import renewal_dates, url
from agenda.event import Event
class TestRenewalDates:
"""Test the renewal_dates function."""
def test_renewal_dates_empty_directory(self) -> None:
"""Test with empty directory."""
with tempfile.TemporaryDirectory() as temp_dir:
with pytest.raises(ValueError, match="max\\(\\) iterable argument is empty"):
renewal_dates(temp_dir)
def test_renewal_dates_no_matching_files(self) -> None:
"""Test with directory containing no matching files."""
with tempfile.TemporaryDirectory() as temp_dir:
# Create some non-matching files
with open(os.path.join(temp_dir, "other_file.csv"), "w") as f:
f.write("test")
with open(os.path.join(temp_dir, "not_export.csv"), "w") as f:
f.write("test")
with pytest.raises(ValueError, match="max\\(\\) iterable argument is empty"):
renewal_dates(temp_dir)
def test_renewal_dates_single_file(self) -> None:
"""Test with single domain export file."""
with tempfile.TemporaryDirectory() as temp_dir:
# Create a domain export file
filename = "export_domains_01_15_2024_02_30_PM.csv"
filepath = os.path.join(temp_dir, filename)
# Create CSV content
csv_data = [
["fqdn", "date_registry_end_utc"],
["example.com", "2024-06-15T00:00:00Z"],
["test.org", "2024-12-31T00:00:00Z"]
]
with open(filepath, "w", newline="") as f:
writer = csv.writer(f)
writer.writerows(csv_data)
result = renewal_dates(temp_dir)
assert len(result) == 2
# Check first domain
assert result[0].name == "domain"
assert result[0].title == "🌐 example.com"
assert result[0].date == date(2024, 6, 15)
assert result[0].url == url + "example.com"
# Check second domain
assert result[1].name == "domain"
assert result[1].title == "🌐 test.org"
assert result[1].date == date(2024, 12, 31)
assert result[1].url == url + "test.org"
def test_renewal_dates_multiple_files_latest_used(self) -> None:
"""Test that the latest file is used when multiple exist."""
with tempfile.TemporaryDirectory() as temp_dir:
# Create older file
older_filename = "export_domains_01_15_2024_02_30_PM.csv"
older_filepath = os.path.join(temp_dir, older_filename)
older_csv_data = [
["fqdn", "date_registry_end_utc"],
["old.com", "2024-06-15T00:00:00Z"]
]
with open(older_filepath, "w", newline="") as f:
writer = csv.writer(f)
writer.writerows(older_csv_data)
# Create newer file
newer_filename = "export_domains_01_16_2024_03_45_PM.csv"
newer_filepath = os.path.join(temp_dir, newer_filename)
newer_csv_data = [
["fqdn", "date_registry_end_utc"],
["new.com", "2024-08-20T00:00:00Z"]
]
with open(newer_filepath, "w", newline="") as f:
writer = csv.writer(f)
writer.writerows(newer_csv_data)
result = renewal_dates(temp_dir)
# Should only get data from newer file
assert len(result) == 1
assert result[0].title == "🌐 new.com"
assert result[0].date == date(2024, 8, 20)
def test_renewal_dates_empty_csv(self) -> None:
"""Test with empty CSV file."""
with tempfile.TemporaryDirectory() as temp_dir:
filename = "export_domains_01_15_2024_02_30_PM.csv"
filepath = os.path.join(temp_dir, filename)
# Create CSV with only headers
csv_data = [["fqdn", "date_registry_end_utc"]]
with open(filepath, "w", newline="") as f:
writer = csv.writer(f)
writer.writerows(csv_data)
result = renewal_dates(temp_dir)
assert result == []
def test_renewal_dates_directory_not_found(self) -> None:
"""Test error handling when directory doesn't exist."""
with pytest.raises(FileNotFoundError):
renewal_dates("/nonexistent/directory")
def test_renewal_dates_malformed_filename(self) -> None:
"""Test with malformed filename that can't be parsed."""
with tempfile.TemporaryDirectory() as temp_dir:
# Create file with name that starts correctly but can't be parsed as date
filename = "export_domains_invalid_date.csv"
filepath = os.path.join(temp_dir, filename)
with open(filepath, "w") as f:
f.write("test")
# Should raise ValueError when trying to parse the malformed date
with pytest.raises(ValueError, match="time data .* does not match format"):
renewal_dates(temp_dir)
def test_renewal_dates_various_date_formats(self) -> None:
"""Test with various date formats in the CSV."""
with tempfile.TemporaryDirectory() as temp_dir:
filename = "export_domains_01_15_2024_02_30_PM.csv"
filepath = os.path.join(temp_dir, filename)
csv_data = [
["fqdn", "date_registry_end_utc"],
["example1.com", "2024-06-15T12:34:56Z"],
["example2.com", "2024-12-31T00:00:00+00:00"],
["example3.com", "2024-03-20T23:59:59.999Z"]
]
with open(filepath, "w", newline="") as f:
writer = csv.writer(f)
writer.writerows(csv_data)
result = renewal_dates(temp_dir)
assert len(result) == 3
assert result[0].date == date(2024, 6, 15)
assert result[1].date == date(2024, 12, 31)
assert result[2].date == date(2024, 3, 20)
class TestConstants:
"""Test module constants."""
def test_url_constant(self) -> None:
"""Test that URL constant has expected value."""
expected_url = "https://admin.gandi.net/domain/01578ef0-a84b-11e7-bdf3-00163e6dc886/"
assert url == expected_url

49
tests/test_events_yaml.py Normal file
View file

@ -0,0 +1,49 @@
"""Tests for events_yaml functionality."""
from datetime import date, datetime, time
from agenda.events_yaml import midnight
class TestMidnight:
"""Test the midnight function."""
def test_midnight_with_date(self) -> None:
"""Test converting date to midnight datetime."""
test_date = date(2024, 6, 15)
result = midnight(test_date)
assert isinstance(result, datetime)
assert result.date() == test_date
assert result.time() == time(0, 0, 0) # Midnight
assert result == datetime(2024, 6, 15, 0, 0, 0)
def test_midnight_different_dates(self) -> None:
"""Test midnight function with different dates."""
test_cases = [
date(2024, 1, 1), # New Year's Day
date(2024, 12, 31), # New Year's Eve
date(2024, 2, 29), # Leap day
date(2024, 7, 4), # Independence Day
]
for test_date in test_cases:
result = midnight(test_date)
assert result.date() == test_date
assert result.time() == time(0, 0, 0)
assert result.hour == 0
assert result.minute == 0
assert result.second == 0
assert result.microsecond == 0
def test_midnight_preserves_year_month_day(self) -> None:
"""Test that midnight preserves the year, month, and day."""
test_date = date(2023, 11, 27)
result = midnight(test_date)
assert result.year == 2023
assert result.month == 11
assert result.day == 27
assert result.hour == 0
assert result.minute == 0
assert result.second == 0

182
tests/test_fx.py Normal file
View file

@ -0,0 +1,182 @@
"""Tests for foreign exchange functionality."""
import json
import os
import tempfile
from decimal import Decimal
from unittest.mock import Mock, patch
import pytest
from agenda.fx import read_cached_rates
class TestReadCachedRates:
"""Test the read_cached_rates function."""
def test_read_cached_rates_none_filename(self) -> None:
"""Test with None filename returns empty dict."""
result = read_cached_rates(None, ["USD", "EUR"])
assert result == {}
def test_read_cached_rates_valid_file(self) -> None:
"""Test reading valid cached rates file."""
currencies = ["USD", "EUR", "JPY"]
data = {
"quotes": {
"GBPUSD": 1.25,
"GBPEUR": 1.15,
"GBPJPY": 150.0,
"GBPCAD": 1.70 # Not requested
}
}
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump(data, f)
filepath = f.name
try:
result = read_cached_rates(filepath, currencies)
assert len(result) == 3
assert result["USD"] == Decimal("1.25")
assert result["EUR"] == Decimal("1.15")
assert result["JPY"] == Decimal("150.0")
assert "CAD" not in result # Not requested
finally:
os.unlink(filepath)
def test_read_cached_rates_missing_currencies(self) -> None:
"""Test with some currencies missing from data."""
currencies = ["USD", "EUR", "CHF"] # CHF not in data
data = {
"quotes": {
"GBPUSD": 1.25,
"GBPEUR": 1.15
# GBPCHF missing
}
}
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump(data, f)
filepath = f.name
try:
result = read_cached_rates(filepath, currencies)
assert len(result) == 2
assert result["USD"] == Decimal("1.25")
assert result["EUR"] == Decimal("1.15")
assert "CHF" not in result # Missing from data
finally:
os.unlink(filepath)
def test_read_cached_rates_empty_currencies_list(self) -> None:
"""Test with empty currencies list."""
data = {
"quotes": {
"GBPUSD": 1.25,
"GBPEUR": 1.15
}
}
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump(data, f)
filepath = f.name
try:
result = read_cached_rates(filepath, [])
assert result == {}
finally:
os.unlink(filepath)
def test_read_cached_rates_no_quotes_key(self) -> None:
"""Test with data missing quotes key."""
currencies = ["USD", "EUR"]
data = {"other_key": "value"} # No quotes key
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump(data, f)
filepath = f.name
try:
with pytest.raises(KeyError):
read_cached_rates(filepath, currencies)
finally:
os.unlink(filepath)
def test_read_cached_rates_file_not_found(self) -> None:
"""Test error handling when file doesn't exist."""
with pytest.raises(FileNotFoundError):
read_cached_rates("/nonexistent/file.json", ["USD"])
def test_read_cached_rates_invalid_json(self) -> None:
"""Test error handling with invalid JSON."""
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
f.write("invalid json content")
filepath = f.name
try:
with pytest.raises(json.JSONDecodeError):
read_cached_rates(filepath, ["USD"])
finally:
os.unlink(filepath)
def test_read_cached_rates_decimal_precision(self) -> None:
"""Test that rates are returned as Decimal with proper precision."""
currencies = ["USD"]
data = {
"quotes": {
"GBPUSD": 1.234567890123456789 # High precision
}
}
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump(data, f)
filepath = f.name
try:
result = read_cached_rates(filepath, currencies)
assert isinstance(result["USD"], Decimal)
# Should preserve reasonable precision from the JSON
# Python's JSON precision may be limited to float precision
expected = Decimal("1.234567890123456789")
assert abs(result["USD"] - expected) < Decimal("0.0000000000000001")
finally:
os.unlink(filepath)
def test_read_cached_rates_various_currency_codes(self) -> None:
"""Test with various currency codes."""
currencies = ["USD", "EUR", "JPY", "CHF", "CAD", "AUD"]
data = {
"quotes": {
"GBPUSD": 1.25,
"GBPEUR": 1.15,
"GBPJPY": 150.0,
"GBPCHF": 1.12,
"GBPCAD": 1.70,
"GBPAUD": 1.85
}
}
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump(data, f)
filepath = f.name
try:
result = read_cached_rates(filepath, currencies)
assert len(result) == 6
for currency in currencies:
assert currency in result
assert isinstance(result[currency], Decimal)
finally:
os.unlink(filepath)

183
tests/test_sun.py Normal file
View file

@ -0,0 +1,183 @@
"""Tests for sun functionality."""
from datetime import datetime
from unittest.mock import Mock, patch
import pytest
from agenda.sun import bristol, sunrise, sunset
class TestBristol:
"""Test the bristol function."""
def test_bristol_returns_observer(self) -> None:
"""Test that bristol returns an ephem.Observer with correct coordinates."""
observer = bristol()
# Check that it's an observer object with the right attributes
assert hasattr(observer, 'lat')
assert hasattr(observer, 'lon')
# Check coordinates are set to Bristol
assert str(observer.lat) == "51:27:16.2" # 51.4545 degrees
assert str(observer.lon) == "-2:35:16.4" # -2.5879 degrees
def test_bristol_observer_type(self) -> None:
"""Test that bristol returns the correct type."""
observer = bristol()
# Should have methods needed for sun calculations
assert hasattr(observer, 'next_rising')
assert hasattr(observer, 'next_setting')
class TestSunrise:
"""Test the sunrise function."""
@patch('agenda.sun.ephem')
def test_sunrise_returns_datetime(self, mock_ephem: Mock) -> None:
"""Test that sunrise returns a datetime object."""
# Mock the observer and sun
mock_observer = Mock()
mock_sun = Mock()
mock_ephem.Sun.return_value = mock_sun
# Mock the rising calculation
mock_rising = Mock()
mock_rising.datetime.return_value = datetime(2024, 6, 15, 5, 30, 0)
mock_observer.next_rising.return_value = mock_rising
result = sunrise(mock_observer)
assert isinstance(result, datetime)
assert result == datetime(2024, 6, 15, 5, 30, 0)
# Verify ephem calls
mock_ephem.Sun.assert_called_once_with(mock_observer)
mock_observer.next_rising.assert_called_once_with(mock_sun)
def test_sunrise_with_real_observer(self) -> None:
"""Test sunrise with a real observer (integration test)."""
observer = bristol()
# Set a specific date for the observer
observer.date = "2024/6/21" # Summer solstice
result = sunrise(observer)
# Should return a datetime object
assert isinstance(result, datetime)
# On summer solstice in Bristol, sunrise should be early morning
assert 3 <= result.hour <= 6 # Roughly between 3-6 AM
# Should be in 2024
assert result.year == 2024
def test_sunrise_different_dates(self) -> None:
"""Test sunrise for different dates."""
observer = bristol()
# Summer solstice (longest day)
observer.date = "2024/6/21"
summer_sunrise = sunrise(observer)
# Winter solstice (shortest day)
observer.date = "2024/12/21"
winter_sunrise = sunrise(observer)
# Summer sunrise should be earlier than winter sunrise
assert summer_sunrise.hour < winter_sunrise.hour
class TestSunset:
"""Test the sunset function."""
@patch('agenda.sun.ephem')
def test_sunset_returns_datetime(self, mock_ephem: Mock) -> None:
"""Test that sunset returns a datetime object."""
# Mock the observer and sun
mock_observer = Mock()
mock_sun = Mock()
mock_ephem.Sun.return_value = mock_sun
# Mock the setting calculation
mock_setting = Mock()
mock_setting.datetime.return_value = datetime(2024, 6, 15, 20, 30, 0)
mock_observer.next_setting.return_value = mock_setting
result = sunset(mock_observer)
assert isinstance(result, datetime)
assert result == datetime(2024, 6, 15, 20, 30, 0)
# Verify ephem calls
mock_ephem.Sun.assert_called_once_with(mock_observer)
mock_observer.next_setting.assert_called_once_with(mock_sun)
def test_sunset_with_real_observer(self) -> None:
"""Test sunset with a real observer (integration test)."""
observer = bristol()
# Set a specific date for the observer
observer.date = "2024/6/21" # Summer solstice
result = sunset(observer)
# Should return a datetime object
assert isinstance(result, datetime)
# On summer solstice in Bristol, sunset should be in evening
assert 19 <= result.hour <= 22 # Roughly between 7-10 PM
# Should be in 2024
assert result.year == 2024
def test_sunset_different_dates(self) -> None:
"""Test sunset for different dates."""
observer = bristol()
# Summer solstice (longest day)
observer.date = "2024/6/21"
summer_sunset = sunset(observer)
# Winter solstice (shortest day)
observer.date = "2024/12/21"
winter_sunset = sunset(observer)
# Summer sunset should be later than winter sunset
assert summer_sunset.hour > winter_sunset.hour
class TestSunIntegration:
"""Integration tests for sun calculations."""
def test_day_length_summer_vs_winter(self) -> None:
"""Test that summer days are longer than winter days."""
observer = bristol()
# Summer solstice
observer.date = "2024/6/21"
summer_sunrise = sunrise(observer)
summer_sunset = sunset(observer)
summer_day_length = summer_sunset - summer_sunrise
# Winter solstice
observer.date = "2024/12/21"
winter_sunrise = sunrise(observer)
winter_sunset = sunset(observer)
winter_day_length = winter_sunset - winter_sunrise
# Summer day should be longer than winter day
assert summer_day_length > winter_day_length
def test_sunrise_before_sunset(self) -> None:
"""Test that sunrise is always before sunset."""
observer = bristol()
observer.date = "2024/6/15" # Arbitrary date
sunrise_time = sunrise(observer)
sunset_time = sunset(observer)
assert sunrise_time < sunset_time