Repository: gpauloski/3pseatBot

Issue:
Birthday Messages
### Describe the Request

Add extension that sends a Happy Birthday message to members.

Commands:
- `/birthday add {user} {date}` (do we need day/month as separate fields)
- `/birthday list`
- `/birthday remove {user}`

Need some mechanism to check birthdays at certain time each day.
https://stackoverflow.com/questions/51530012/how-can-i-run-an-async-function-using-the-schedule-library

### Sample Code

_No response_

### Additional Code or Information

_No response_

Discussion:

Patch:
diff --git a/threepseat/birthdays/__init__.py b/threepseat/birthdays/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/threepseat/birthdays/commands.py b/threepseat/birthdays/commands.py
new file mode 100644
index 0000000..20e2b78
--- /dev/null
+++ b/threepseat/birthdays/commands.py
@@ -0,0 +1,200 @@
+from __future__ import annotations
+
+import asyncio
+import datetime
+import enum
+import logging
+import time
+
+import discord
+from discord import app_commands
+from discord.ext import tasks
+
+from threepseat.birthdays.data import Birthday
+from threepseat.birthdays.data import Birthdays
+from threepseat.commands.commands import log_interaction
+from threepseat.utils import primary_channel
+
+logger = logging.getLogger(__name__)
+
+# Check birthdays at 9am
+BIRTHDAY_CHECK_HOUR = 9
+BIRTHDAY_CHECK_MINUTE = 0
+
+
+class Months(enum.Enum):
+    """Months enum for command options."""
+
+    January = 1
+    February = 2
+    March = 3
+    April = 4
+    May = 5
+    June = 6
+    July = 7
+    August = 8
+    September = 9
+    October = 10
+    November = 11
+    December = 12
+
+
+class BirthdayCommands(app_commands.Group):
+    """App commands group for birthday messages."""
+
+    def __init__(self, db_path: str) -> None:
+        """Init BirthdayCommands.
+
+        Args:
+            db_path (str): path to database to use.
+        """
+        self.database = Birthdays(db_path)
+
+        super().__init__(
+            name='birthdays',
+            description='Guild member birthday messages',
+            guild_only=True,
+        )
+
+    def birthday_task(self, client: discord.Client) -> tasks.Loop[tasks.LF]:
+        """Return async task that will check for birthdays once per day."""
+
+        @tasks.loop(hours=24)
+        async def _checker() -> None:
+            logger.info('starting daily birthday check...')
+            for guild in client.guilds:
+                await self.send_birthday_messages(guild)
+            logger.info('finished daily birthday check')
+
+        @_checker.before_loop
+        async def _wait_until_start() -> None:
+            # source: https://stackoverflow.com/questions/51530012
+            now = datetime.datetime.now()
+            future = datetime.datetime(
+                year=now.year,
+                month=now.month,
+                day=now.day,
+                hour=BIRTHDAY_CHECK_HOUR,
+                minute=BIRTHDAY_CHECK_MINUTE,
+            )
+            if (
+                now.hour >= BIRTHDAY_CHECK_HOUR
+                and now.minute > BIRTHDAY_CHECK_MINUTE
+            ):
+                future += datetime.timedelta(days=1)
+            await asyncio.sleep((future - now).seconds)
+
+        return _checker
+
+    async def send_birthday_messages(self, guild: discord.Guild) -> None:
+        """Checks birthdays in guild and sends messages if they are today."""
+        month = datetime.datetime.now().month
+        day = datetime.datetime.now().day
+
+        channel = primary_channel(guild)
+        if channel is None:
+            return
+
+        for birthday in self.database.all(guild.id):
+            if birthday.birth_day == day and birthday.birth_month == month:
+                member = guild.get_member(birthday.user_id)
+                if member is not None:
+                    await channel.send(f'Happy Birthday, {member.mention}!')
+
+    @app_commands.command(name='add', description='Add a member birthday')
+    @app_commands.describe(
+        member='Member the birthday is for',
+        month='Month of the member\'s birthday',
+        day='Day of the month of the member\'s birthday',
+    )
+    @app_commands.check(log_interaction)
+    async def add(
+        self,
+        interaction: discord.Interaction,
+        member: discord.Member,
+        month: Months,
+        day: app_commands.Range[int, 1, 31],
+    ) -> None:
+        """Add a birthday for the member of the guild."""
+        assert interaction.guild is not None
+
+        try:
+            # Use leap year as year
+            datetime.datetime(year=2020, month=month.value, day=day)
+        except ValueError as e:
+            await interaction.response.send_message(
+                f'Invalid birthday: {e}.',
+                ephemeral=True,
+            )
+            return
+
+        birthday = Birthday(
+            guild_id=interaction.guild.id,
+            user_id=member.id,
+            author_id=interaction.user.id,
+            creation_time=int(time.time()),
+            birth_day=day,
+            birth_month=month.value,
+        )
+
+        self.database.update(birthday)
+
+        await interaction.response.send_message(
+            f'Added birthday for {member.mention}.',
+            ephemeral=True,
+        )
+
+    @app_commands.command(
+        name='list',
+        description='List birthdays in the guild',
+    )
+    @app_commands.check(log_interaction)
+    async def list(self, interaction: discord.Interaction) -> None:
+        """List birthdays in the guild."""
+        assert interaction.guild is not None
+
+        birthdays = self.database.all(interaction.guild.id)
+
+        if len(birthdays) == 0:
+            await interaction.response.send_message(
+                'There are no birthdays in this guild yet.',
+                ephemeral=True,
+            )
+            return
+
+        await interaction.response.defer(thinking=True, ephemeral=True)
+
+        birthdays.sort(key=lambda b: (b.birth_month, b.birth_day))
+        birthdays_strs: list[str] = []
+        for birthday in birthdays:
+            member = interaction.guild.get_member(birthday.user_id)
+            month = Months(birthday.birth_month).name
+            day = birthday.birth_day
+            if member is not None:
+                birthdays_strs.append(f'{member.mention}: {month} {day}')
+
+        await interaction.followup.send('\n'.join(birthdays_strs))
+
+    @app_commands.command(name='remove', description='Remove a birthday')
+    @app_commands.describe(member='Member to remove birthday for')
+    @app_commands.check(log_interaction)
+    async def remove(
+        self,
+        interaction: discord.Interaction,
+        member: discord.Member,
+    ) -> None:
+        """Remove a birthday from the guild."""
+        assert interaction.guild is not None
+
+        if self.database.get(interaction.guild.id, member.id) is None:
+            await interaction.response.send_message(
+                f'A birthday has not been added for {member.mention}.',
+                ephemeral=True,
+            )
+            return
+
+        self.database.remove(interaction.guild.id, member.id)
+        await interaction.response.send_message(
+            f'Removed birthday for {member.mention}.',
+            ephemeral=True,
+        )
diff --git a/threepseat/birthdays/data.py b/threepseat/birthdays/data.py
new file mode 100644
index 0000000..6894b69
--- /dev/null
+++ b/threepseat/birthdays/data.py
@@ -0,0 +1,122 @@
+from __future__ import annotations
+
+import contextlib
+import functools
+import os
+import sqlite3
+from collections.abc import Generator
+from typing import NamedTuple
+
+from threepseat.database import create_table
+from threepseat.database import named_tuple_parameters
+from threepseat.database import named_tuple_parameters_update
+
+
+class UnknownBirthdayError(Exception):
+    """Exception raised when removing a birthday that does not exist."""
+
+    pass
+
+
+class Birthday(NamedTuple):
+    """Row in birthday table.
+
+    guild_id and user_id server as compound primary key.
+    """
+
+    guild_id: int
+    """Guild this birthday was add in."""
+    user_id: int
+    """User this birthday is for."""
+    author_id: int
+    """User that added the birthday."""
+    creation_time: int
+    """Unix timestamp of when the birthday was added."""
+    birth_day: int
+    """Day of month of birthday."""
+    birth_month: int
+    """Number of month of birthday."""
+
+
+class Birthdays:
+    """Birthday data manager."""
+
+    def __init__(self, db_path: str) -> None:
+        """Init Birthdays.
+
+        Args:
+            db_path (str): path to sqlite database.
+        """
+        self.db_path = db_path
+
+        if len(os.path.dirname(self.db_path)) > 0:
+            os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
+
+        self.all = functools.cache(self._all)
+        self.get = functools.cache(self._get)
+
+        with self.connect() as db:
+            create_table(db, 'birthdays', Birthday)
+
+    @contextlib.contextmanager
+    def connect(self) -> Generator[sqlite3.Connection, None, None]:
+        """Database connection context manager."""
+        with contextlib.closing(sqlite3.connect(self.db_path)) as db:
+            with db:
+                yield db
+
+    def _all(self, guild_id: int) -> list[Birthday]:
+        """Get all birthdays in guild."""
+        with self.connect() as db:
+            rows = db.execute(
+                'SELECT * FROM birthdays WHERE guild_id = :guild_id',
+                {'guild_id': guild_id},
+            ).fetchall()
+            return [Birthday(*row) for row in rows]
+
+    def _get(self, guild_id: int, user_id: int) -> Birthday | None:
+        """Get users birthday."""
+        with self.connect() as db:
+            rows = db.execute(
+                'SELECT * FROM birthdays '
+                'WHERE guild_id = :guild_id AND user_id = :user_id',
+                {'guild_id': guild_id, 'user_id': user_id},
+            ).fetchall()
+            if len(rows) == 0:
+                return None
+            else:
+                return Birthday(*rows[0])
+
+    def update(self, birthday: Birthday) -> None:
+        """Add or update a birthday."""
+        with self.connect() as db:
+            res = db.execute(
+                'UPDATE birthdays '
+                f'SET {named_tuple_parameters_update(Birthday)} '
+                'WHERE guild_id = :guild_id AND user_id = :user_id',
+                birthday._asdict(),
+            )
+            if res.rowcount == 0:
+                res = db.execute(
+                    'INSERT INTO birthdays VALUES '
+                    f'{named_tuple_parameters(Birthday)}',
+                    birthday._asdict(),
+                )
+            self.get.cache_clear()
+            self.all.cache_clear()
+
+    def remove(self, guild_id: int, user_id: int) -> None:
+        """Remove a birthday."""
+        birthday = self.get(guild_id, user_id)
+        if birthday is None:
+            raise UnknownBirthdayError()
+
+        with self.connect() as db:
+            db.execute(
+                'DELETE FROM birthdays '
+                'WHERE guild_id = :guild_id AND user_id = :user_id',
+                {'guild_id': guild_id, 'user_id': user_id},
+            )
+
+        self.get.cache_clear()
+        self.all.cache_clear()
diff --git a/threepseat/bot.py b/threepseat/bot.py
index 9420a1a..04667d6 100644
--- a/threepseat/bot.py
+++ b/threepseat/bot.py
@@ -5,6 +5,7 @@ import logging
 import discord
 from discord.ext import commands
 
+from threepseat.birthdays.commands import BirthdayCommands
 from threepseat.commands.commands import registered_app_commands
 from threepseat.commands.custom import CustomCommands
 from threepseat.listeners.listeners import registered_listeners
@@ -24,6 +25,7 @@ class Bot(commands.Bot):
         self,
         *,
         playing_title: str | None = None,
+        birthday_commands: BirthdayCommands | None = None,
         custom_commands: CustomCommands | None = None,
         rules_commands: RulesCommands | None = None,
         sound_commands: SoundCommands | None = None,
@@ -34,6 +36,8 @@ class Bot(commands.Bot):
         Args:
             playing_title (str, optional): set bot as playing this title
                 (default: None).
+            birthday_commands (BirthdayCommands, optional): birthday commands
+                object to register with bot (default: None).
             custom_commands (CustomCommands, optional): custom commands
                 object to register with bot (default: None).
             rules_commands (RulesCommands, optional): rules commands
@@ -44,6 +48,7 @@ class Bot(commands.Bot):
                 object to register with bot (default: None).
         """
         self.playing_title = playing_title
+        self.birthday_commands = birthday_commands
         self.custom_commands = custom_commands
         self.rules_commands = rules_commands
         self.sound_commands = sound_commands
@@ -91,6 +96,12 @@ class Bot(commands.Bot):
             self.add_listener(listener.func, listener.event)
         logger.info(f'registered {len(listeners)} listeners')
 
+        if self.birthday_commands is not None:
+            self.tree.add_command(self.birthday_commands)
+            self.birthday_checker = self.birthday_commands.birthday_task(self)
+            self.birthday_checker.start()
+            logger.info('registered birthday command group')
+
         if self.custom_commands is not None:
             self.tree.add_command(self.custom_commands)
             await self.custom_commands.register_all(self)
diff --git a/threepseat/main.py b/threepseat/main.py
index 184ffb9..7a81892 100644
--- a/threepseat/main.py
+++ b/threepseat/main.py
@@ -10,6 +10,7 @@ from typing import Any
 
 import threepseat
 from threepseat import config
+from threepseat.birthdays.commands import BirthdayCommands
 from threepseat.bot import Bot
 from threepseat.commands.custom import CustomCommands
 from threepseat.logging import configure_logging
@@ -25,6 +26,7 @@ logger = logging.getLogger(__name__)
 
 async def amain(cfg: config.Config, shutdown_event: asyncio.Event) -> None:
     """Run asyncio services."""
+    birthday_commands = BirthdayCommands(cfg.sqlite_database)
     custom_commands = CustomCommands(cfg.sqlite_database)
     rules_commands = RulesCommands(cfg.sqlite_database)
     sounds = Sounds(cfg.sqlite_database, cfg.sounds_path)
@@ -33,6 +35,7 @@ async def amain(cfg: config.Config, shutdown_event: asyncio.Event) -> None:
 
     bot = Bot(
         playing_title=cfg.playing_title,
+        birthday_commands=birthday_commands,
         custom_commands=custom_commands,
         rules_commands=rules_commands,
         sound_commands=sound_commands,


Test patch:
diff --git a/tests/birthdays/__init__.py b/tests/birthdays/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/birthdays/commands_test.py b/tests/birthdays/commands_test.py
new file mode 100644
index 0000000..c55c976
--- /dev/null
+++ b/tests/birthdays/commands_test.py
@@ -0,0 +1,254 @@
+from __future__ import annotations
+
+import datetime
+from asyncio import sleep as asleep
+from collections.abc import Generator
+from unittest import mock
+
+import discord
+import pytest
+
+from testing.mock import MockChannel
+from testing.mock import MockGuild
+from testing.mock import MockInteraction
+from testing.mock import MockMember
+from testing.utils import extract
+from threepseat.birthdays.commands import BirthdayCommands
+from threepseat.birthdays.commands import Months
+from threepseat.birthdays.data import Birthday
+
+
+BIRTHDAY = Birthday(
+    guild_id=1234,
+    user_id=5678,
+    author_id=9012,
+    creation_time=0,
+    birth_day=1,
+    birth_month=2,
+)
+
+
+@pytest.fixture
+def birthdays(tmp_file: str) -> Generator[BirthdayCommands, None, None]:
+    yield BirthdayCommands(tmp_file)
+
+
+@pytest.mark.asyncio
+async def test_birthday_task(birthdays) -> None:
+    with mock.patch('discord.Client'):
+        client = discord.Client()  # type: ignore
+        client.guilds = [MockGuild('guild', 42)]  # type: ignore
+
+    with (
+        mock.patch('asyncio.sleep'),
+        mock.patch.object(birthdays, 'send_birthday_messages') as mock_send,
+    ):
+        task = birthdays.birthday_task(client)
+        task.change_interval(seconds=0.01)
+        task.start()
+        while task.current_loop == 0:
+            await asleep(0.005)
+        task.cancel()
+
+        assert mock_send.await_count >= 1
+
+
+@pytest.mark.asyncio
+async def test_birthday_task_sleeps_until_start(birthdays) -> None:
+    from threepseat.birthdays import commands
+
+    with mock.patch('discord.Client'):
+        client = discord.Client()  # type: ignore
+        client.guilds = []  # type: ignore
+
+    with mock.patch('asyncio.sleep'):
+        # Force time to be in future
+        commands.BIRTHDAY_CHECK_HOUR = 23
+        commands.BIRTHDAY_CHECK_MINUTE = 59
+
+        task = birthdays.birthday_task(client)
+        task.change_interval(seconds=0.01)
+        task.start()
+        while task.current_loop == 0:
+            await asleep(0.005)
+        task.cancel()
+
+        # Force time to be in past
+        commands.BIRTHDAY_CHECK_HOUR = 0
+        commands.BIRTHDAY_CHECK_MINUTE = 0
+
+        task = birthdays.birthday_task(client)
+        task.change_interval(seconds=0.01)
+        task.start()
+        while task.current_loop == 0:
+            await asleep(0.005)
+        task.cancel()
+
+
+@pytest.mark.asyncio
+async def test_send_birthday_messages(birthdays) -> None:
+    guild = MockGuild('guild', BIRTHDAY.guild_id)
+    channel = MockChannel('channel', 42)
+
+    birthdays.database.update(BIRTHDAY._replace(user_id=1))
+    birthdays.database.update(
+        BIRTHDAY._replace(
+            user_id=2,
+            birth_month=datetime.datetime.now().month,
+            birth_day=datetime.datetime.now().day,
+        ),
+    )
+    birthdays.database.update(
+        BIRTHDAY._replace(
+            user_id=3,
+            birth_month=datetime.datetime.now().month,
+            birth_day=datetime.datetime.now().day,
+        ),
+    )
+    member = MockMember('user', 2, guild)
+
+    with (
+        mock.patch.object(channel, 'send', mock.AsyncMock()) as mock_send,
+        mock.patch.object(guild, 'get_member', side_effect=[member, None]),
+        mock.patch(
+            'threepseat.birthdays.commands.primary_channel',
+            return_value=channel,
+        ),
+    ):
+        await birthdays.send_birthday_messages(guild)
+        assert mock_send.await_count == 1
+
+
+@pytest.mark.asyncio
+async def test_send_birthday_messages_no_channel_found(birthdays) -> None:
+    guild = MockGuild('guild', BIRTHDAY.guild_id)
+
+    with mock.patch(
+        'threepseat.birthdays.commands.primary_channel',
+        return_value=None,
+    ):
+        await birthdays.send_birthday_messages(guild)
+
+
+@pytest.mark.asyncio
+async def test_add_birthday(birthdays) -> None:
+    add_ = extract(birthdays.add)
+
+    guild = MockGuild('guild', BIRTHDAY.guild_id)
+    interaction = MockInteraction(birthdays.add, user='user', guild=guild)
+
+    await add_(
+        birthdays,
+        interaction,
+        MockMember('user', 42, guild),
+        Months(BIRTHDAY.birth_month),
+        BIRTHDAY.birth_day,
+    )
+
+    birthday = birthdays.database.get(guild.id, 42)
+    assert birthday is not None
+    assert birthday.birth_month == BIRTHDAY.birth_month
+    assert birthday.birth_day == BIRTHDAY.birth_day
+
+    assert interaction.responded
+    assert (
+        interaction.response_message is not None
+        and 'Added birthday' in interaction.response_message
+    )
+
+
+@pytest.mark.asyncio
+async def test_add_birthday_invalid_date(birthdays) -> None:
+    add_ = extract(birthdays.add)
+
+    guild = MockGuild('guild', BIRTHDAY.guild_id)
+    interaction = MockInteraction(birthdays.add, user='user', guild=guild)
+
+    await add_(
+        birthdays,
+        interaction,
+        MockMember('user', 42, guild),
+        Months.February,
+        31,
+    )
+
+    assert interaction.responded
+    assert (
+        interaction.response_message is not None
+        and 'Invalid birthday' in interaction.response_message
+    )
+
+
+@pytest.mark.asyncio
+async def test_list_birthdays(birthdays) -> None:
+    list_ = extract(birthdays.list)
+
+    guild = MockGuild('guild', BIRTHDAY.guild_id)
+    interaction = MockInteraction(birthdays.list, user='user', guild=guild)
+
+    birthdays.database.update(BIRTHDAY._replace(user_id=1))
+    birthdays.database.update(BIRTHDAY._replace(user_id=2))
+    birthdays.database.update(BIRTHDAY._replace(guild_id=0, user_id=1))
+
+    # Mock second call to get_member to return None
+    _members = [MockMember('user', 1, guild), None]
+
+    with mock.patch.object(guild, 'get_member', side_effect=_members):
+        await list_(birthdays, interaction)
+
+    assert interaction.followed
+    assert interaction.followup_message is not None
+    # One output line because there are three rows, two in the guild we
+    # query from, and one of the members in the guild will not be found
+    assert len(interaction.followup_message.split('\n')) == 1
+
+
+@pytest.mark.asyncio
+async def test_list_birthdays_empty(birthdays) -> None:
+    list_ = extract(birthdays.list)
+
+    guild = MockGuild('guild', BIRTHDAY.guild_id)
+    interaction = MockInteraction(birthdays.list, user='user', guild=guild)
+
+    await list_(birthdays, interaction)
+
+    assert interaction.responded
+    assert (
+        interaction.response_message is not None
+        and 'no birthdays' in interaction.response_message
+    )
+
+
+@pytest.mark.asyncio
+async def test_remove_birthday(birthdays) -> None:
+    remove_ = extract(birthdays.remove)
+
+    guild = MockGuild('guild', BIRTHDAY.guild_id)
+    interaction = MockInteraction(birthdays.remove, user='user', guild=guild)
+
+    birthdays.database.update(BIRTHDAY)
+
+    await remove_(
+        birthdays,
+        interaction,
+        MockMember('user', BIRTHDAY.user_id, guild),
+    )
+
+    assert birthdays.database.get(BIRTHDAY.guild_id, BIRTHDAY.user_id) is None
+    assert interaction.responded
+    assert interaction.response_message is not None
+    assert 'Removed' in interaction.response_message
+
+
+@pytest.mark.asyncio
+async def test_remove_birthday_missing(birthdays) -> None:
+    remove_ = extract(birthdays.remove)
+
+    guild = MockGuild('guild', BIRTHDAY.guild_id)
+    interaction = MockInteraction(birthdays.remove, user='user', guild=guild)
+
+    await remove_(birthdays, interaction, MockMember('user', 42, guild))
+
+    assert interaction.responded
+    assert interaction.response_message is not None
+    assert 'birthday has not been added' in interaction.response_message
diff --git a/tests/birthdays/data_test.py b/tests/birthdays/data_test.py
new file mode 100644
index 0000000..7ab9792
--- /dev/null
+++ b/tests/birthdays/data_test.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+import os
+import pathlib
+
+import pytest
+
+from threepseat.birthdays.data import Birthday
+from threepseat.birthdays.data import Birthdays
+from threepseat.birthdays.data import UnknownBirthdayError
+
+
+BIRTHDAY = Birthday(
+    guild_id=1234,
+    user_id=5678,
+    author_id=9012,
+    creation_time=0,
+    birth_day=1,
+    birth_month=1,
+)
+
+
+def test_makes_parent_dirs(tmp_path: pathlib.Path) -> None:
+    db_parent_path = str(tmp_path / 'dir1' / 'dir2')
+    db_path = os.path.join(db_parent_path, 'test.db')
+    Birthdays(db_path=db_path)
+    assert os.path.isdir(db_parent_path)
+
+
+def test_birthdays_table_created(tmp_file: str) -> None:
+    birthdays = Birthdays(db_path=tmp_file)
+
+    with birthdays.connect() as db:
+        res = db.execute(
+            'SELECT COUNT(*) FROM sqlite_master '
+            'WHERE type="table" AND name="birthdays"',
+        ).fetchone()
+        assert res[0] == 1
+
+
+def test_add_get(tmp_file: str) -> None:
+    birthdays = Birthdays(db_path=tmp_file)
+
+    birthdays.update(BIRTHDAY)
+    assert birthdays.get(BIRTHDAY.guild_id, BIRTHDAY.user_id) == BIRTHDAY
+
+
+def test_update(tmp_file: str) -> None:
+    birthdays = Birthdays(db_path=tmp_file)
+
+    birthdays.update(BIRTHDAY)
+    birthdays.update(BIRTHDAY._replace(birth_day=42))
+    birthday = birthdays.get(BIRTHDAY.guild_id, BIRTHDAY.user_id)
+    assert birthday is not None and birthday.birth_day == 42
+
+
+def test_all(tmp_file: str) -> None:
+    birthdays = Birthdays(db_path=tmp_file)
+
+    birthdays.update(BIRTHDAY._replace(user_id=42))
+    birthdays.update(BIRTHDAY._replace(user_id=43))
+    birthdays.update(BIRTHDAY._replace(user_id=44))
+    birthdays.update(BIRTHDAY._replace(guild_id=1))
+    assert len(birthdays.all(BIRTHDAY.guild_id)) == 3
+
+
+def test_remove(tmp_file: str) -> None:
+    birthdays = Birthdays(db_path=tmp_file)
+
+    birthdays.update(BIRTHDAY)
+    assert birthdays.get(BIRTHDAY.guild_id, BIRTHDAY.user_id) == BIRTHDAY
+    birthdays.remove(BIRTHDAY.guild_id, BIRTHDAY.user_id)
+    assert birthdays.get(BIRTHDAY.guild_id, BIRTHDAY.user_id) is None
+
+
+def test_remove_missing(tmp_file: str) -> None:
+    birthdays = Birthdays(db_path=tmp_file)
+    with pytest.raises(UnknownBirthdayError):
+        birthdays.remove(0, 0)
diff --git a/tests/bot_test.py b/tests/bot_test.py
index a4ffda3..9dfdbd2 100644
--- a/tests/bot_test.py
+++ b/tests/bot_test.py
@@ -7,6 +7,7 @@ import pytest
 
 from testing.mock import MockGuild
 from testing.mock import MockUser
+from threepseat.birthdays.commands import BirthdayCommands
 from threepseat.bot import Bot
 from threepseat.commands.custom import CustomCommands
 from threepseat.reminders.commands import ReminderCommands
@@ -57,6 +58,7 @@ async def test_bot_startup(tmp_file: str, caplog) -> None:
         ):
             sounds = Sounds(tmp_file, data_path='/tmp/threepseat-test')
             bot = Bot(
+                birthday_commands=BirthdayCommands(tmp_file),
                 custom_commands=CustomCommands(tmp_file),
                 sound_commands=SoundCommands(sounds),
                 rules_commands=RulesCommands(tmp_file),