From c3df9e69dc8b250b7c3ecea1a1bb13078fc626b9 Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Thu, 17 Jul 2025 02:14:59 +0000 Subject: [PATCH 1/6] run black, isort, and ssort on tests --- tests/conftest.py | 9 +++++---- tests/helpers/__init__.py | 1 + tests/helpers/utils.py | 2 +- tests/mocks/__init__.py | 1 + tests/mocks/async_app.py | 4 ++-- tests/test_auth.py | 8 +++++--- tests/test_bot.py | 4 ++-- tests/test_event.py | 5 +++-- tests/test_server.py | 1 + 9 files changed, 21 insertions(+), 14 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2fc19aa..0b6aa8c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ """Pytest Fixtures""" + import json import pathlib from threading import Thread @@ -7,8 +8,6 @@ import pytest from fastapi.testclient import TestClient -import bot -import config import database import server @@ -125,8 +124,10 @@ def sample_event_date(): "tags": "", "rsvp_count": None, "created_at": "2023-11-15T18:50:35Z", - "description": "Join us for a special event as we take a group trip to local parks to admire " - + "and appreciate the decommissioned military tanks that are on display.", + "description": ( + "Join us for a special event as we take a group trip to local parks to admire " + "and appreciate the decommissioned military tanks that are on display." + ), "uuid": "e70fb83b-df54-4333-9f02-1746ec1d62ee", "nid": "1", "data_as_of": "2023-12-07T16:40:14Z", diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index aaf6421..661bb2d 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -1,2 +1,3 @@ """Module for housing Pytest helper functions""" + from .utils import * diff --git a/tests/helpers/utils.py b/tests/helpers/utils.py index a2170ff..194c01b 100644 --- a/tests/helpers/utils.py +++ b/tests/helpers/utils.py @@ -3,7 +3,7 @@ import urllib.parse -# pylint: disable=too-many-arguments +# pylint: disable=too-many-arguments, too-many-positional-arguments def create_slack_request_payload( command: str, token: str = "1CnbxdlkN3Ag2AafGvsp81za", diff --git a/tests/mocks/__init__.py b/tests/mocks/__init__.py index dad1ab1..13b2fcf 100644 --- a/tests/mocks/__init__.py +++ b/tests/mocks/__init__.py @@ -1,3 +1,4 @@ """Module for housing Pytest mock classes and functions""" + from .async_app import * from .mock_response import * diff --git a/tests/mocks/async_app.py b/tests/mocks/async_app.py index 88e1943..4c75ea5 100644 --- a/tests/mocks/async_app.py +++ b/tests/mocks/async_app.py @@ -11,7 +11,7 @@ def __init__(self) -> None: async def chat_postMessage( self, channel, blocks, text, unfurl_links, unfurl_media - ): # pylint: disable=invalid-name + ): # pylint: disable=invalid-name, too-many-arguments, too-many-positional-arguments """Simulates posting a new Slack message""" del channel, blocks, text, unfurl_links, unfurl_media @@ -30,7 +30,7 @@ async def users_info(self, user=""): } -class AsyncApp: +class AsyncApp: # pylint: disable=too-few-public-methods """Simulates slack_bolt.async_app's AsyncApp""" def __init__(self) -> None: diff --git a/tests/test_auth.py b/tests/test_auth.py index 5b88a08..d4b7281 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,6 +1,7 @@ """ Tests functions contained in src/auth.py """ + import os import pytest @@ -13,21 +14,22 @@ class TestAuth: """Groups tests for auth.py into a single scope""" @pytest.mark.asyncio - async def test_is_admin_when_user_is_not_admin(self, mock_slack_bolt_async_app): + async def test_is_admin_when_user_is_not_admin(self, _mock_slack_bolt_async_app): """Tests when a user is NOT a workspace admin""" result = await auth.is_admin("regular_user") assert result is False @pytest.mark.asyncio - async def test_is_admin_when_user_is_admin(self, mock_slack_bolt_async_app): + async def test_is_admin_when_user_is_admin(self, _mock_slack_bolt_async_app): """Tests when a user is a workspace admin""" result = await auth.is_admin("admin_user") assert result is True @pytest.mark.asyncio - async def test_generation_of_expected_hash(self, mock_slack_bolt_async_app): + async def test_generation_of_expected_hash(self, _mock_slack_bolt_async_app): + """Tests the generation of the expected hash for Slack requests.""" os.environ["SIGNING_SECRET"] = "super_secret" result = await auth.generate_expected_hash("946702800", b"I am a test body") diff --git a/tests/test_bot.py b/tests/test_bot.py index e867456..34f7708 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -19,7 +19,7 @@ class TestBot: @pytest.mark.asyncio async def test_post_or_update_messages_expansion_with_next_week_already_posted( - self, caplog, db_cleanup, mock_slack_bolt_async_app + self, caplog, _db_cleanup, _mock_slack_bolt_async_app ): """ post_or_update_messages fails if it determines that a @@ -64,7 +64,7 @@ async def test_post_or_update_messages_expansion_with_next_week_already_posted( @pytest.mark.asyncio async def test_post_or_update_messages_expansion_without_new_weeks_posts( - self, caplog, db_cleanup, mock_slack_bolt_async_app + self, caplog, _db_cleanup, _mock_slack_bolt_async_app ): """ post_or_update_messages will allow for additional messages to be posted diff --git a/tests/test_event.py b/tests/test_event.py index b664722..b3c0ae7 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -2,9 +2,9 @@ Tests the parsing of events data """ -import event import datetime import pytz +import event def test_parsing_location_of_event_with_full_details(sample_event_date): @@ -161,7 +161,8 @@ def test_generate_blocks_no_description(): def test_generate_blocks_whitespace_description(): - """Test that the text field is not present in the section block when description is whitespace.""" + """Test that the text field is not present in the section block when + description is whitespace.""" mock_event = event.Event( title="Test Title", group_name="Test Group", diff --git a/tests/test_server.py b/tests/test_server.py index 0fd81ef..c5faec8 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,6 +1,7 @@ """ Tests for the server.py file. """ + import hashlib import hmac import os From 36a4fa79c22c56e1a9ff1244e16a703a5b8384a3 Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Sat, 19 Jul 2025 03:18:21 +0000 Subject: [PATCH 2/6] remove all pylint disables --- src/bot.py | 8 +- src/database.py | 67 ++++-- src/event.py | 124 +++++----- src/message_builder.py | 2 +- src/server.py | 3 +- tests/conftest.py | 42 +--- tests/helpers/utils.py | 34 ++- tests/mocks/async_app.py | 34 ++- tests/test_auth.py | 15 +- tests/test_bot.py | 23 +- tests/test_event.py | 480 +++++++++++++++++++++++++++------------ tests/test_server.py | 34 ++- 12 files changed, 547 insertions(+), 319 deletions(-) diff --git a/src/bot.py b/src/bot.py index ed2f744..fba32d8 100644 --- a/src/bot.py +++ b/src/bot.py @@ -209,8 +209,8 @@ async def periodically_delete_old_messages(): while True: try: await database.delete_old_messages() - except Exception: # pylint: disable=broad-except - print(traceback.format_exc()) + except sqlite3.Error: + logging.error("An unexpected error occurred: %s", traceback.format_exc()) os._exit(1) await asyncio.sleep(60 * 60 * 24) # 24 hours @@ -225,8 +225,8 @@ async def periodically_check_api(): while True: try: await check_api() - except Exception: # pylint: disable=broad-except - print(traceback.format_exc()) + except sqlite3.Error: + logging.error("An unexpected error occurred: %s", traceback.format_exc()) os._exit(1) await asyncio.sleep(60 * 60) # 60 minutes x 60 seconds diff --git a/src/database.py b/src/database.py index 5b28222..fe33124 100644 --- a/src/database.py +++ b/src/database.py @@ -3,32 +3,42 @@ import datetime import os import sqlite3 -from typing import Generator, Union +from typing import Union DB_PATH = os.path.abspath(os.environ.get("DB_PATH", "./slack-events-bot.db")) -def get_connection(commit: bool = False) -> Generator: - """ - Yields a SQLite connection to another method. +class Connection: + """Context manager for SQLite connection.""" - Once the other method has finished, - the transaction if committed if the commit parameter is true, - and then the connection is always closed. - """ - conn = sqlite3.connect(DB_PATH) + def __init__(self, commit: bool = False): + self.commit = commit + self.conn = None - yield conn + def __enter__(self): + self.conn = sqlite3.connect(DB_PATH) + return self.conn - if commit: - conn.commit() + def __exit__(self, exc_type, value, traceback): + if self.commit: + self.conn.commit() + self.conn.close() - conn.close() + +def get_connection(commit: bool = False) -> Connection: + """ + Returns a context manager for a SQLite connection. + + Usage: + with get_connection(commit=True) as conn: + # do something with conn + """ + return Connection(commit) def create_tables(): """Create database tables needed for slack events bot""" - for conn in get_connection(commit=True): + with get_connection(commit=True) as conn: cur = conn.cursor() cur.executescript( """ @@ -77,7 +87,7 @@ async def create_message( week, message, message_timestamp, slack_channel_id, sequence_position: int ): """Create a record of a message sent in slack for a week""" - for conn in get_connection(commit=True): + with get_connection(commit=True) as conn: cur = conn.cursor() # get database's channel id for slack channel id cur.execute( @@ -96,7 +106,7 @@ async def create_message( async def update_message(week, message, message_timestamp, slack_channel_id): """Updates a record of a message sent in slack for a week""" - for conn in get_connection(commit=True): + with get_connection(commit=True) as conn: cur = conn.cursor() # get database's channel id for slack channel id cur.execute( @@ -114,7 +124,7 @@ async def update_message(week, message, message_timestamp, slack_channel_id): async def get_messages(week) -> list: """Get all messages sent in slack for a week""" - for conn in get_connection(): + with get_connection() as conn: cur = conn.cursor() cur.execute( """SELECT m.message, m.message_timestamp, c.slack_channel_id, m.sequence_position @@ -139,7 +149,7 @@ async def get_messages(week) -> list: async def get_most_recent_message_for_channel(slack_channel_id) -> dict: """Get the most recently posted message for a subscribed Slack channel""" - for conn in get_connection(): + with get_connection() as conn: cur = conn.cursor() cur.execute( """SELECT m.week, m.message, m.message_timestamp @@ -168,7 +178,7 @@ async def get_most_recent_message_for_channel(slack_channel_id) -> dict: async def get_slack_channel_ids() -> list: """Get all slack channels that the bot is configured for""" - for conn in get_connection(): + with get_connection() as conn: cur = conn.cursor() cur.execute("SELECT slack_channel_id FROM channels") return [x[0] for x in cur.fetchall()] @@ -178,7 +188,7 @@ async def get_slack_channel_ids() -> list: async def add_channel(slack_channel_id): """Add a slack channel to post in for the bot""" - for conn in get_connection(commit=True): + with get_connection(commit=True) as conn: cur = conn.cursor() cur.execute( "INSERT INTO channels (slack_channel_id) VALUES (?)", [slack_channel_id] @@ -187,14 +197,14 @@ async def add_channel(slack_channel_id): async def remove_channel(channel_id): """Remove a slack channel to post in from the bot""" - for conn in get_connection(commit=True): + with get_connection(commit=True) as conn: cur = conn.cursor() cur.execute("DELETE FROM channels WHERE slack_channel_id = ?", [channel_id]) async def delete_old_messages(days_back=90): """delete all messages and cooldowns with timestamp older than current timestamp - days_back""" - for conn in get_connection(commit=True): + with get_connection(commit=True) as conn: cur = conn.cursor() cur.execute( "DELETE FROM messages where cast(message_timestamp as decimal) < ?", @@ -216,12 +226,21 @@ async def delete_old_messages(days_back=90): ) +def clear_db(): + """Clear all data from the database tables.""" + with get_connection(commit=True) as conn: + cur = conn.cursor() + cur.execute("DELETE FROM channels") + cur.execute("DELETE FROM messages") + cur.execute("DELETE FROM cooldowns") + + async def create_cooldown(accessor: str, resource: str, cooldown_minutes: int) -> None: """ Upserts a cooldown record for an entity which will let the system know when to make the resource available to them once again. """ - for conn in get_connection(commit=True): + with get_connection(commit=True) as conn: cur = conn.cursor() cur.execute( """INSERT INTO cooldowns (accessor, resource, expires_at) @@ -247,7 +266,7 @@ async def get_cooldown_expiry_time(accessor: str, resource: str) -> Union[str, N Returns the time at which an accessor is able to access a resource or None if no restriction has ever been put in place. """ - for conn in get_connection(): + with get_connection() as conn: cur = conn.cursor() cur.execute( """SELECT expires_at FROM cooldowns diff --git a/src/event.py b/src/event.py index d3477ad..19eff81 100644 --- a/src/event.py +++ b/src/event.py @@ -12,26 +12,27 @@ def parse_location(event_json): if event_json["venue"] is None: return None - if None not in ( - event_json["venue"]["name"], - event_json["venue"]["address"], - event_json["venue"]["city"], - event_json["venue"]["state"], - event_json["venue"]["zip"], - ): - return ( - f"{event_json['venue']['name']} at " - f"{event_json['venue']['address']} {event_json['venue']['city']}, " - f"{event_json['venue']['state']} {event_json['venue']['zip']}" - ) - - if ( - event_json["venue"]["lat"] is not None - and event_json["venue"]["lon"] is not None - ): - return f"lat/long: {event_json['venue']['lat']}, {event_json['venue']['lon']}" - - return f"{event_json['venue']['name']}" + name = event_json["venue"].get("name") + address = event_json["venue"].get("address") + city = event_json["venue"].get("city") + state = event_json["venue"].get("state") + zip_code = event_json["venue"].get("zip") + lat = event_json["venue"].get("lat") + lon = event_json["venue"].get("lon") + + # Option 1: Full address + if all([name, address, city, state, zip_code]): + return f"{name} at " f"{address} {city}, " f"{state} {zip_code}" + + # Option 2: Lat/Lon + if lat is not None and lon is not None: + return f"lat/long: {lat}, {lon}" + + # Option 3: Just name + if name: + return name + + return None def truncate_string(string, length=250): @@ -46,9 +47,13 @@ def get_location_url(location): if not location or not location.strip(): return None + search_query = location + if location.startswith("lat/long: "): + search_query = location.replace("lat/long: ", "") + return ( "" + f"{urllib.parse.quote(search_query)}|{location}>" ) @@ -80,35 +85,50 @@ class Event: message from an event """ - # pylint: disable=too-many-instance-attributes - # Events have lots of data that we need to save together - def __init__( - self, *, title, group_name, description, location, time, url, status, uuid - ): - # pylint: disable=too-many-arguments - self.title = title - self.group_name = group_name - self.description = description - self.location = location - self.time = time - self.url = url - self.status = status - self.uuid = uuid - - # creates a struct of event information used to compose different formats of the event message - @classmethod - def from_event_json(cls, event_json): - """Create an event class object from the raw event json returned by the OpenApi""" - return cls( - title=event_json["event_name"], - group_name=event_json["group_name"], - description=event_json["description"], - location=parse_location(event_json), - time=parser.isoparse(event_json["time"]), - url=event_json["url"], - status=event_json["status"], - uuid=event_json["uuid"], - ) + def __init__(self, event_json): + self._event_json = event_json + + @property + def title(self): + """Returns the event title.""" + return self._event_json["event_name"] + + @property + def group_name(self): + """Returns the event group name.""" + return self._event_json["group_name"] + + @property + def description(self): + """Returns the event description.""" + return self._event_json["description"] + + @property + def location(self): + """Returns the event location.""" + return parse_location(self._event_json) + + @property + def time(self): + """Returns the event time.""" + if self._event_json["time"] is None: + return None + return parser.isoparse(self._event_json["time"]) + + @property + def url(self): + """Returns the event URL.""" + return self._event_json["url"] + + @property + def status(self): + """Returns the event status.""" + return self._event_json["status"] + + @property + def uuid(self): + """Returns the event UUID.""" + return self._event_json["uuid"] def _build_fields(self): fields = [] @@ -124,7 +144,7 @@ def _build_fields(self): fields.append({"type": "mrkdwn", "text": "*Status*"}) fields.append({"type": "mrkdwn", "text": status_text}) - location_text = get_location_url(self.location) + location_text = get_location_url(parse_location(self._event_json)) if location_text and location_text.strip(): fields.append({"type": "mrkdwn", "text": "*Location*"}) fields.append({"type": "mrkdwn", "text": location_text}) @@ -178,7 +198,7 @@ def generate_text(self): if status_text and status_text.strip(): lines.append(f"Status: {status_text}") - location_text = self.location # get_location_url already handles None/empty + location_text = get_location_url(self.location) if location_text and location_text.strip(): lines.append(f"Location: {location_text}") diff --git a/src/message_builder.py b/src/message_builder.py index 2e38b5a..d005f1a 100644 --- a/src/message_builder.py +++ b/src/message_builder.py @@ -50,7 +50,7 @@ async def build_single_event_block( """ Returns the blocks (content and divider), text, and text length for a single event """ - event = Event.from_event_json(event_data) + event = Event(event_data) # ignore event if it's not in the current week if event.time < week_start or event.time > week_end: diff --git a/src/server.py b/src/server.py index 626d2af..6eb0fec 100644 --- a/src/server.py +++ b/src/server.py @@ -12,8 +12,7 @@ import re import sys import threading -from collections.abc import Awaitable, Callable -from typing import Union +from typing import Awaitable, Callable, Union import uvicorn from fastapi import HTTPException, Request, Response diff --git a/tests/conftest.py b/tests/conftest.py index 0b6aa8c..a349940 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,6 @@ import json import pathlib -from threading import Thread import mocks import pytest @@ -18,35 +17,10 @@ def test_client(): return TestClient(server.API) -@pytest.fixture -def threads_appear_dead(monkeypatch): - """Include this fixture if you'd like for all your threads to be reported as dead.""" - monkeypatch.setattr(Thread, "is_alive", lambda x: False) - - -@pytest.fixture -def db_cleanup(): - """ - Fixture to clean the database after tests. - """ - database.create_tables() - - yield - - for conn in database.get_connection(): - cur = conn.cursor() - - cur.executescript( - """ - SELECT 'DELETE FROM ' || name - FROM sqlite_master - WHERE type = 'table'; - """ - ) - - conn.commit() - - conn.close() +@pytest.fixture(autouse=True) +def clear_db(): + """Clear the database after each test that uses it.""" + database.clear_db() @pytest.fixture(scope="session") @@ -94,14 +68,6 @@ def single_event_data(): } -@pytest.fixture -def mock_slack_bolt_async_app(request, monkeypatch): - """ - Monkeypatch slack_bolt.async_app's AsyncApp with our stub - """ - monkeypatch.setattr(f"{request.param}.SLACK_APP", mocks.AsyncApp()) - - @pytest.fixture def sample_event_date(): """Return fully-populated event dictionary""" diff --git a/tests/helpers/utils.py b/tests/helpers/utils.py index 194c01b..4276620 100644 --- a/tests/helpers/utils.py +++ b/tests/helpers/utils.py @@ -3,22 +3,23 @@ import urllib.parse -# pylint: disable=too-many-arguments, too-many-positional-arguments -def create_slack_request_payload( - command: str, - token: str = "1CnbxdlkN3Ag2AafGvsp81za", - team_id: str = "LGPpTuQPsQx", - team_domain: str = "super_cool_domain", - channel_id: str = "jhVOsIAWtNW", - channel_name: str = "Testing", - user_id: str = "2xIIwe9Rs6y", - user_name: str = "thetester", - text: str = "", - api_app_id: str = "QpysuvDZwgb", - is_enterprise_install: str = "false", - response_url: str = "https://hooks.slack.com/commands/some-info", -) -> bytes: +def create_slack_request_payload(**kwargs) -> bytes: """Creates a representative payload that we would expect to receive from Slack's API.""" + command = kwargs.get("command", "") + token = kwargs.get("token", "1CnbxdlkN3Ag2AafGvsp81za") + team_id = kwargs.get("team_id", "LGPpTuQPsQx") + team_domain = kwargs.get("team_domain", "super_cool_domain") + channel_id = kwargs.get("channel_id", "jhVOsIAWtNW") + channel_name = kwargs.get("channel_name", "Testing") + user_id = kwargs.get("user_id", "2xIIwe9Rs6y") + user_name = kwargs.get("user_name", "thetester") + text = kwargs.get("text", "") + api_app_id = kwargs.get("api_app_id", "QpysuvDZwgb") + is_enterprise_install = kwargs.get("is_enterprise_install", "false") + response_url = kwargs.get( + "response_url", "https://hooks.slack.com/commands/some-info" + ) + sample_payload = ( f"token={token}&team_id={team_id}&team_domain={team_domain}&channel_id{channel_id}&" f"channel_name={channel_name}&user_id={user_id}" @@ -28,6 +29,3 @@ def create_slack_request_payload( ) return bytes(sample_payload, "utf-8") - - -# pylint: enable=too-many-arguments diff --git a/tests/mocks/async_app.py b/tests/mocks/async_app.py index 4c75ea5..19a5d6e 100644 --- a/tests/mocks/async_app.py +++ b/tests/mocks/async_app.py @@ -9,12 +9,9 @@ class Client: def __init__(self) -> None: pass - async def chat_postMessage( - self, channel, blocks, text, unfurl_links, unfurl_media - ): # pylint: disable=invalid-name, too-many-arguments, too-many-positional-arguments + async def chat_post_message(self, **kwargs): """Simulates posting a new Slack message""" - del channel, blocks, text, unfurl_links, unfurl_media - + _ = kwargs return {"ts": "1503435956.000247"} async def chat_update(self, ts, channel, blocks, text): @@ -24,14 +21,29 @@ async def chat_update(self, ts, channel, blocks, text): async def users_info(self, user=""): """Simulates getting info on a user""" - return { - "ok": True, - "user": {"id": user, "name": "Tester", "is_admin": "admin" in user.lower()}, - } + if user == "admin_user": + return { + "ok": True, + "user": {"id": user, "name": "Tester", "is_admin": True}, + } + if user == "regular_user": + return { + "ok": True, + "user": {"id": user, "name": "Tester", "is_admin": False}, + } + return {"ok": False, "error": "user_not_found"} -class AsyncApp: # pylint: disable=too-few-public-methods +class AsyncApp: """Simulates slack_bolt.async_app's AsyncApp""" def __init__(self) -> None: - self.client = Client() + self._client = Client() + + @property + def client(self): + """Returns the mocked client.""" + return self._client + + async def process(self, *args, **kwargs): + """Mocks the process method of AsyncApp.""" diff --git a/tests/test_auth.py b/tests/test_auth.py index d4b7281..8f0c36b 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -3,32 +3,39 @@ """ import os +from unittest.mock import AsyncMock import pytest import auth +from config import SLACK_APP -@pytest.mark.parametrize("mock_slack_bolt_async_app", ["auth"], indirect=True) class TestAuth: """Groups tests for auth.py into a single scope""" @pytest.mark.asyncio - async def test_is_admin_when_user_is_not_admin(self, _mock_slack_bolt_async_app): + async def test_is_admin_when_user_is_not_admin(self): """Tests when a user is NOT a workspace admin""" + SLACK_APP.client.users_info = AsyncMock( + return_value={"ok": True, "user": {"is_admin": False}} + ) result = await auth.is_admin("regular_user") assert result is False @pytest.mark.asyncio - async def test_is_admin_when_user_is_admin(self, _mock_slack_bolt_async_app): + async def test_is_admin_when_user_is_admin(self): """Tests when a user is a workspace admin""" + SLACK_APP.client.users_info = AsyncMock( + return_value={"ok": True, "user": {"is_admin": True}} + ) result = await auth.is_admin("admin_user") assert result is True @pytest.mark.asyncio - async def test_generation_of_expected_hash(self, _mock_slack_bolt_async_app): + async def test_generation_of_expected_hash(self): """Tests the generation of the expected hash for Slack requests.""" os.environ["SIGNING_SECRET"] = "super_secret" diff --git a/tests/test_bot.py b/tests/test_bot.py index 34f7708..594f2b0 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -3,23 +3,25 @@ """ import datetime +from unittest.mock import AsyncMock import pytest import pytz import database from bot import post_or_update_messages +from config import SLACK_APP week = datetime.datetime.strptime("10/22/2023", "%m/%d/%Y").replace(tzinfo=pytz.utc) -@pytest.mark.parametrize("mock_slack_bolt_async_app", ["bot"], indirect=True) class TestBot: """Groups tests for bot.py into a single scope""" @pytest.mark.asyncio async def test_post_or_update_messages_expansion_with_next_week_already_posted( - self, caplog, _db_cleanup, _mock_slack_bolt_async_app + self, + caplog, ): """ post_or_update_messages fails if it determines that a @@ -34,7 +36,7 @@ async def test_post_or_update_messages_expansion_with_next_week_already_posted( # This message is for the next week await database.create_message( - "2023-10-29 00:00:00+00:00", + datetime.datetime.fromisoformat("2023-10-29 00:00:00+00:00"), "test", "1698119853.135399", slack_id, @@ -42,7 +44,7 @@ async def test_post_or_update_messages_expansion_with_next_week_already_posted( ) # Message for this week await database.create_message( - "2023-10-22 00:00:00+00:00", + datetime.datetime.fromisoformat("2023-10-22 00:00:00+00:00"), "test", "1698119853.135399", slack_id, @@ -50,6 +52,10 @@ async def test_post_or_update_messages_expansion_with_next_week_already_posted( ) # Try adding more message than what currently exists + SLACK_APP.client.chat_update = AsyncMock(return_value={"ok": True}) + SLACK_APP.client.chat_postMessage = AsyncMock( + return_value={"ok": True, "ts": "123.456"} + ) await post_or_update_messages( week, [{"text": "message 1", "blocks": []}, {"text": "message 2", "blocks": []}], @@ -64,7 +70,8 @@ async def test_post_or_update_messages_expansion_with_next_week_already_posted( @pytest.mark.asyncio async def test_post_or_update_messages_expansion_without_new_weeks_posts( - self, caplog, _db_cleanup, _mock_slack_bolt_async_app + self, + caplog, ): """ post_or_update_messages will allow for additional messages to be posted @@ -77,7 +84,7 @@ async def test_post_or_update_messages_expansion_without_new_weeks_posts( await database.add_channel(slack_id) # Message for this week await database.create_message( - "2023-10-22 00:00:00+00:00", + datetime.datetime.fromisoformat("2023-10-22 00:00:00+00:00"), "test", "1698119853.135399", slack_id, @@ -85,6 +92,10 @@ async def test_post_or_update_messages_expansion_without_new_weeks_posts( ) # Try adding more message than what currently exists + SLACK_APP.client.chat_update = AsyncMock(return_value={"ok": True}) + SLACK_APP.client.chat_postMessage = AsyncMock( + return_value={"ok": True, "ts": "123.456"} + ) await post_or_update_messages( week, [{"text": "message 1", "blocks": []}, {"text": "message 2", "blocks": []}], diff --git a/tests/test_event.py b/tests/test_event.py index b3c0ae7..6f8e82a 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -3,15 +3,24 @@ """ import datetime + import pytz + import event def test_parsing_location_of_event_with_full_details(sample_event_date): """Happy path scenario where all event fields are populated""" - result = event.parse_location(sample_event_date) - - assert result == "Gower Estates Park at 24 Evelyn Ave, Greenville, SC 29607" + result = "Gower Estates Park at 24 Evelyn Ave, Greenville, SC 29607" + assert result == event.parse_location(sample_event_date) + expected_url = ( + "" + ) + assert ( + event.get_location_url(event.parse_location(sample_event_date)) == expected_url + ) def test_parsing_location_of_event_with_missing_venue(sample_event_date): @@ -19,9 +28,7 @@ def test_parsing_location_of_event_with_missing_venue(sample_event_date): event_data_without_venue = sample_event_date event_data_without_venue["venue"] = None - result = event.parse_location(event_data_without_venue) - - assert result is None + assert event.parse_location(event_data_without_venue) is None def test_parsing_location_of_event_missing_state(sample_event_date): @@ -30,9 +37,14 @@ def test_parsing_location_of_event_missing_state(sample_event_date): event_data_without_state["venue"]["state"] = None result = event.parse_location(event_data_without_state) - assert result == "lat/long: 34.8300191, -82.3510954" + expected_url = ( + "" + ) + assert event.get_location_url(result) == expected_url + def test_parsing_location_of_event_missing_state_and_latitude(sample_event_date): """Ensure venue name is returned if state and latitude are missing from venue info""" @@ -41,22 +53,121 @@ def test_parsing_location_of_event_missing_state_and_latitude(sample_event_date) event_data_without_state_and_lat["venue"]["lat"] = None result = event.parse_location(event_data_without_state_and_lat) - assert result == "Gower Estates Park" + expected_url = ( + "" + ) + assert event.get_location_url(result) == expected_url + + +def test_parsing_location_of_event_with_only_name(): + """Test that only the venue name is returned if only name is present.""" + result = event.parse_location({"venue": {"name": "Test Venue"}}) + assert result == "Test Venue" + + expected_url = ( + "" + ) + assert event.get_location_url(result) == expected_url + + +def test_parsing_location_of_event_with_name_and_lat_lon(): + """Test that lat/lon is prioritized over name if both are present but no full address.""" + result = event.parse_location( + {"venue": {"name": "Test Venue", "lat": 12.34, "lon": 56.78}} + ) + assert result == "lat/long: 12.34, 56.78" + + expected_url = ( + "" + ) + assert event.get_location_url(result) == expected_url + + +def test_parsing_location_of_event_missing_address(): + """Test that full address is still formatted if address is missing but other + parts are present.""" + event_data_missing_address = { + "venue": { + "name": "Test Venue", + "city": "Test City", + "state": "TS", + "zip": "12345", + "lat": 12.34, + "lon": 56.78, + } + } + assert event.parse_location(event_data_missing_address) == "lat/long: 12.34, 56.78" + expected_url = ( + "" + ) + assert ( + event.get_location_url(event.parse_location(event_data_missing_address)) + == expected_url + ) + + +def test_parsing_location_of_event_missing_city(): + """Test that full address is still formatted if city is missing but other parts are present.""" + event_data_missing_city = { + "venue": { + "name": "Test Venue", + "address": "123 Test St", + "state": "TS", + "zip": "12345", + "lat": 12.34, + "lon": 56.78, + } + } + result = event.parse_location(event_data_missing_city) + expected_plain_text_location = "lat/long: 12.34, 56.78" + assert result == expected_plain_text_location + expected_url = ( + "" + ) + assert event.get_location_url(result) == expected_url + + +def test_parsing_location_of_event_missing_zip(): + """Test that full address is still formatted if zip is missing but other parts are present.""" + event_data_missing_zip = { + "venue": { + "name": "Test Venue", + "address": "123 Test St", + "city": "Test City", + "state": "TS", + "lat": 12.34, + "lon": 56.78, + } + } + result = event.parse_location(event_data_missing_zip) + expected_plain_text_location = "lat/long: 12.34, 56.78" + assert result == expected_plain_text_location + expected_url = ( + "" + ) + assert event.get_location_url(result) == expected_url + def test_generate_blocks_no_title(): """Test that no header block is generated when the event has no title.""" - mock_event = event.Event( - title="", - group_name="Test Group", - description="Test Description", - location="Test Location", - time=datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc), - url="http://example.com", - status="upcoming", - uuid="test-uuid", - ) + mock_event_json = { + "event_name": "", + "group_name": "Test Group", + "description": "Test Description", + "venue": {"name": "Test Location"}, + "time": datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc).isoformat(), + "url": "http://example.com", + "status": "upcoming", + "uuid": "test-uuid", + } + mock_event = event.Event(mock_event_json) blocks = mock_event.generate_blocks() # Assert that the first block is not a header block assert not any(b.get("type") == "header" for b in blocks) @@ -64,16 +175,17 @@ def test_generate_blocks_no_title(): def test_generate_blocks_whitespace_title(): """Test that no header block is generated when the event has only whitespace title.""" - mock_event = event.Event( - title=" ", - group_name="Test Group", - description="Test Description", - location="Test Location", - time=datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc), - url="http://example.com", - status="upcoming", - uuid="test-uuid", - ) + mock_event_json = { + "event_name": " ", + "group_name": "Test Group", + "description": "Test Description", + "venue": {"name": "Test Location"}, + "time": datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc).isoformat(), + "url": "http://example.com", + "status": "upcoming", + "uuid": "test-uuid", + } + mock_event = event.Event(mock_event_json) blocks = mock_event.generate_blocks() # Assert that the first block is not a header block assert not any(b.get("type") == "header" for b in blocks) @@ -101,22 +213,21 @@ def test_get_location_url_whitespace(): def test_generate_blocks_conditional_fields(): """Test that generate_blocks conditionally includes fields.""" - mock_event = event.Event( - title="Test Title", - group_name="", # Empty group name - description="", # Empty description - location="", # Empty location - time=datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc), - url="", # Empty URL - status="upcoming", - uuid="test-uuid", - ) + mock_event_json = { + "event_name": "Test Title", + "group_name": "", # Empty group name + "description": "", # Empty description + "venue": {"name": ""}, # Empty location + "time": datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc).isoformat(), + "url": "", # Empty URL + "status": "upcoming", + "uuid": "test-uuid", + } + mock_event = event.Event(mock_event_json) blocks = mock_event.generate_blocks() # Expect only status and time fields to be present section_block = blocks[1] # Assuming header is blocks[0] and section is blocks[1] - assert ( - len(section_block["fields"]) == 4 - ) # Status (label + value), Time (label + value) + assert len(section_block["fields"]) == 4 # Status and Time fields (label + value) assert section_block["fields"][0]["text"] == "*Status*" assert section_block["fields"][1]["text"] == "Upcoming ✅" assert section_block["fields"][2]["text"] == "*Time*" @@ -124,153 +235,242 @@ def test_generate_blocks_conditional_fields(): def test_generate_text_conditional_lines(): """Test that generate_text conditionally includes lines.""" - mock_event = event.Event( - title="Test Title", - group_name="", - description="", - location="", - time=datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc), - url="", - status="upcoming", - uuid="test-uuid", - ) + mock_event_json = { + "event_name": "Test Title", + "group_name": "", + "description": "", + "venue": {"name": ""}, + "time": datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc).isoformat(), + "url": "", + "status": "upcoming", + "uuid": "test-uuid", + } + mock_event = event.Event(mock_event_json) text = mock_event.generate_text() - expected_lines = [ + assert text.split("\n") == [ "Test Title", "Status: Upcoming ✅", f"Time: {event.print_datetime(mock_event.time)}", ] - assert text.split("\n") == expected_lines def test_generate_blocks_no_description(): """Test that the text field is not present in the section block when description is empty.""" - mock_event = event.Event( - title="Test Title", - group_name="Test Group", - description="", # Empty description - location="Test Location", - time=datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc), - url="http://example.com", - status="upcoming", - uuid="test-uuid", - ) + mock_event_json = { + "event_name": "Test Title", + "group_name": "Test Group", + "description": "", # Empty description + "venue": {"name": "Test Location"}, + "time": datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc).isoformat(), + "url": "http://example.com", + "status": "upcoming", + "uuid": "test-uuid", + } + mock_event = event.Event(mock_event_json) blocks = mock_event.generate_blocks() - section_block = blocks[1] - assert "text" not in section_block + assert "text" not in blocks[1] def test_generate_blocks_whitespace_description(): """Test that the text field is not present in the section block when description is whitespace.""" - mock_event = event.Event( - title="Test Title", - group_name="Test Group", - description=" \n\t ", # Whitespace description - location="Test Location", - time=datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc), - url="http://example.com", - status="upcoming", - uuid="test-uuid", - ) + mock_event_json = { + "event_name": "Test Title", + "group_name": "Test Group", + "description": " \n\t ", # Whitespace description + "venue": {"name": "Test Location"}, + "time": datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc).isoformat(), + "url": "http://example.com", + "status": "upcoming", + "uuid": "test-uuid", + } + mock_event = event.Event(mock_event_json) blocks = mock_event.generate_blocks() - section_block = blocks[1] - assert "text" not in section_block + assert "text" not in blocks[1] def test_generate_blocks_with_description(): """Test that the text field is present in the section block when description is not empty.""" - mock_event = event.Event( - title="Test Title", - group_name="Test Group", - description="This is a test description.", - location="Test Location", - time=datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc), - url="http://example.com", - status="upcoming", - uuid="test-uuid", - ) + mock_event_json = { + "event_name": "Test Title", + "group_name": "Test Group", + "description": "This is a test description.", + "venue": {"name": "Test Location"}, + "time": datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc).isoformat(), + "url": "http://example.com", + "status": "upcoming", + "uuid": "test-uuid", + } + mock_event = event.Event(mock_event_json) blocks = mock_event.generate_blocks() - section_block = blocks[1] - assert "text" in section_block - assert section_block["text"]["text"] == "This is a test description." + assert "text" in blocks[1] + assert blocks[1]["text"]["text"] == "This is a test description." def test_generate_text_no_group_name(): """Test that the group name line is omitted when group_name is empty.""" - mock_event = event.Event( - title="Test Title", - group_name="", - description="Test Description", - location="Test Location", - time=datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc), - url="http://example.com", - status="upcoming", - uuid="test-uuid", - ) + mock_event_json = { + "event_name": "Test Title", + "group_name": "", + "description": "Test Description", + "venue": {"name": "Test Location"}, + "time": datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc).isoformat(), + "url": "http://example.com", + "status": "upcoming", + "uuid": "test-uuid", + } + mock_event = event.Event(mock_event_json) text = mock_event.generate_text() assert "Group Name:" not in text def test_generate_text_no_url(): """Test that the link line is omitted when url is empty.""" - mock_event = event.Event( - title="Test Title", - group_name="Test Group", - description="Test Description", - location="Test Location", - time=datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc), - url="", - status="upcoming", - uuid="test-uuid", - ) + mock_event_json = { + "event_name": "Test Title", + "group_name": "Test Group", + "description": "Test Description", + "venue": {"name": "Test Location"}, + "time": datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc).isoformat(), + "url": "", + "status": "upcoming", + "uuid": "test-uuid", + } + mock_event = event.Event(mock_event_json) text = mock_event.generate_text() assert "Link:" not in text def test_generate_text_no_status(): """Test that the status line is omitted when status is empty.""" - mock_event = event.Event( - title="Test Title", - group_name="Test Group", - description="Test Description", - location="Test Location", - time=datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc), - url="http://example.com", - status="", - uuid="test-uuid", - ) + mock_event_json = { + "event_name": "Test Title", + "group_name": "Test Group", + "description": "Test Description", + "venue": {"name": "Test Location"}, + "time": datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc).isoformat(), + "url": "http://example.com", + "status": "", + "uuid": "test-uuid", + } + mock_event = event.Event(mock_event_json) text = mock_event.generate_text() assert "Status:" not in text def test_generate_text_no_location(): """Test that the location line is omitted when location is empty.""" - mock_event = event.Event( - title="Test Title", - group_name="Test Group", - description="Test Description", - location="", - time=datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc), - url="http://example.com", - status="upcoming", - uuid="test-uuid", - ) + mock_event_json = { + "event_name": "Test Title", + "group_name": "Test Group", + "description": "Test Description", + "venue": {"name": ""}, + "time": datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc).isoformat(), + "url": "http://example.com", + "status": "upcoming", + "uuid": "test-uuid", + } + mock_event = event.Event(mock_event_json) text = mock_event.generate_text() assert "Location:" not in text def test_generate_text_no_time(): """Test that the time line is omitted when time is None.""" - mock_event = event.Event( - title="Test Title", - group_name="Test Group", - description="Test Description", - location="Test Location", - time=None, - url="http://example.com", - status="upcoming", - uuid="test-uuid", - ) + mock_event_json = { + "event_name": "Test Title", + "group_name": "Test Group", + "description": "Test Description", + "venue": {"name": "Test Location"}, + "time": None, + "url": "http://example.com", + "status": "upcoming", + "uuid": "test-uuid", + } + mock_event = event.Event(mock_event_json) text = mock_event.generate_text() assert "Time:" not in text + + +def test_generate_text_full_event(): + """Test that generate_text produces the expected output for a full event.""" + mock_event_json = { + "event_name": "Test Title", + "group_name": "Test Group", + "description": "Test Description", + "venue": { + "name": "Test Location", + "address": "123 Test St", + "city": "Test City", + "state": "TS", + "zip": "12345", + }, + "time": datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc).isoformat(), + "url": "http://example.com", + "status": "upcoming", + "uuid": "test-uuid", + } + mock_event = event.Event(mock_event_json) + text = mock_event.generate_text() + expected_text = ( + "Test Title\n" + "Description: Test Description\n" + "Link: http://example.com\n" + "Status: Upcoming ✅\n" + "Location: " + + event.get_location_url("Test Location at 123 Test St Test City, TS 12345") + + "\n" + f"Time: {event.print_datetime(mock_event.time)}" + ) + assert text == expected_text + + +def test_generate_blocks_full_event(): + """Test that generate_blocks produces the expected output for a full event.""" + mock_event_json = { + "event_name": "Test Title", + "group_name": "Test Group", + "description": "Test Description", + "venue": { + "name": "Test Location", + "address": "123 Test St", + "city": "Test City", + "state": "TS", + "zip": "12345", + }, + "time": datetime.datetime(2025, 7, 16, 10, 0, 0, tzinfo=pytz.utc).isoformat(), + "url": "http://example.com", + "status": "upcoming", + "uuid": "test-uuid", + } + mock_event = event.Event(mock_event_json) + blocks = mock_event.generate_blocks() + expected_blocks = [ + { + "type": "header", + "text": {"type": "plain_text", "text": "Test Title"}, + }, + { + "type": "section", + "fields": [ + {"type": "mrkdwn", "text": "*Test Group*"}, + {"type": "mrkdwn", "text": ""}, + {"type": "mrkdwn", "text": "*Status*"}, + {"type": "mrkdwn", "text": "Upcoming ✅"}, + {"type": "mrkdwn", "text": "*Location*"}, + { + "type": "mrkdwn", + "text": event.get_location_url( + "Test Location at 123 Test St Test City, TS 12345" + ), + }, + {"type": "mrkdwn", "text": "*Time*"}, + { + "type": "plain_text", + "text": event.print_datetime(mock_event.time), + }, + ], + "text": {"type": "plain_text", "text": "Test Description"}, + }, + ] + assert blocks == expected_blocks diff --git a/tests/test_server.py b/tests/test_server.py index c5faec8..2ca9222 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -5,8 +5,8 @@ import hashlib import hmac import os -import threading import time +import unittest.mock import helpers import pytest @@ -22,18 +22,20 @@ def test_health_check_healthy_threads(test_client): assert response.content == b'{"detail":"Everything is lookin\' good!"}' -def test_health_check_with_a_dead_thread( - test_client, threads_appear_dead -): # pylint: disable=unused-argument +def test_health_check_with_a_dead_thread(test_client): """Tests what happens if a dead thread is found whenever this endpoint is hit.""" - response = test_client.get("healthz") + with unittest.mock.patch("threading.enumerate") as mock_enumerate: + mock_thread = unittest.mock.Mock() + mock_thread.is_alive.return_value = False + mock_thread.name = "DeadThread" + mock_enumerate.return_value = [mock_thread] - first_thread = threading.enumerate()[0] + response = test_client.get("healthz") - assert response.status_code == 500 - assert response.json() == { - "detail": f"The {first_thread.name} thread has died. This container will soon restart." - } + assert response.status_code == 500 + assert response.json() == { + "detail": f"The {mock_thread.name} thread has died. This container will soon restart." + } TEAM_DOMAIN = "team_awesome" @@ -44,9 +46,7 @@ def test_health_check_with_a_dead_thread( ) -def test_check_api_whenever_someone_executes_it_for_first_time( - test_client, db_cleanup -): # pylint: disable=unused-argument +def test_check_api_whenever_someone_executes_it_for_first_time(test_client): """Whenever an entity executes /check_api for the first time it should run successfully.""" response = test_client.post( "/slack/events", @@ -69,9 +69,7 @@ def test_check_api_whenever_someone_executes_it_for_first_time( @pytest.mark.asyncio -async def test_check_api_whenever_someone_executes_it_after_expiry( - test_client, db_cleanup # pylint: disable=unused-argument -): +async def test_check_api_whenever_someone_executes_it_after_expiry(test_client): """ Whenever an entity has run /check_api before, and their cooldown window has expired, then they should be able to run the command again. @@ -96,9 +94,7 @@ async def test_check_api_whenever_someone_executes_it_after_expiry( @pytest.mark.asyncio -async def test_check_api_whenever_someone_executes_it_before_expiry( - test_client, db_cleanup # pylint: disable=unused-argument -): +async def test_check_api_whenever_someone_executes_it_before_expiry(test_client): """ Whenever an entity has run /check_api before, and their cooldown window has NOT expired, then they should receive a message telling them to try again later. From d1e2ddf619aff67c98caeea3a26dbc549698c884 Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Sat, 19 Jul 2025 03:18:54 +0000 Subject: [PATCH 3/6] add pre commit hook --- .pre-commit-config.yaml | 52 +++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 3 ++- 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d5a1db2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,52 @@ + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - repo: https://github.com/psf/black + rev: 24.4.2 + hooks: + - id: black + - repo: https://github.com/PyCQA/isort + rev: 5.13.2 + hooks: + - id: isort + - repo: https://github.com/pylint-dev/pylint + rev: v3.2.5 + hooks: + - id: pylint + args: ["--fail-under=10"] + additional_dependencies: [ + "aiohttp==3.11.10", + "fastapi==0.115.6", + "python-dateutil==2.9.0.post0", + "pytz==2024.2", + "slack_bolt==1.21.3", + "slack_sdk==3.34.0", + "uvicorn==0.32.1", + "black==24.10.0", + "httpx==0.28.0", + "isort==5.13.2", + "pytest==8.3.4", + "pytest-asyncio==0.24.0", + "ssort==0.14.0", + "pre-commit==3.7.1" + ] + - repo: local + hooks: + - id: ssort + name: ssort + entry: python -m ssort + language: system + types: [python] + args: ["--check"] + - id: pytest + name: pytest + entry: bash -c 'source .envrc && pytest' + language: system + types: [python] + pass_filenames: false diff --git a/pyproject.toml b/pyproject.toml index 76f3a5b..f6a9b07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,8 @@ test = [ "pylint==3.3.2", "pytest==8.3.4", "pytest-asyncio==0.24.0", - "ssort==0.14.0" + "ssort==0.14.0", + "pre-commit==3.7.1" ] [project.urls] From 4ff6a2905eae258e60087e6e7a2c547977802f84 Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Sat, 19 Jul 2025 03:19:02 +0000 Subject: [PATCH 4/6] modify ci to check test code --- .github/workflows/ci.yml | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72586b0..f9f281c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,17 +26,19 @@ jobs: - name: Install Python dependencies run: pip install .[test] - - name: Black - run: python -m black --check src/ - - - name: isort - run: python -m isort --check src/ - - - name: ssort - run: python -m ssort --check src/ - - - name: Pylint - run: python -m pylint src/ + - name: Lint and Format src/ + run: | + python -m black --check src/ + python -m isort --check src/ + python -m ssort --check src/ + python -m pylint --fail-under=10 src/ + + - name: Lint and Format tests/ + run: | + python -m black --check tests/ + python -m isort --check tests/ + python -m ssort --check tests/ + python -m pylint --fail-under=10 tests/ - name: Pytest run: python -m pytest tests/ From a8d0a361b030a6122fc3161f7fbea0cfb479191d Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Sat, 19 Jul 2025 03:25:52 +0000 Subject: [PATCH 5/6] use same python version in ci as tool-versions --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9f281c..725ec50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - name: Install Python uses: actions/setup-python@v4 with: - python-version: '3.11.5' + python-version: '3.13.0' cache: 'pip' - name: Install Python dependencies From 2013f42a449a350626612a6c901bf4aac7ae8ef2 Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Sat, 19 Jul 2025 03:31:30 +0000 Subject: [PATCH 6/6] fix tests in ci --- .github/workflows/ci.yml | 1 + src/database.py | 7 ++++++- tests/conftest.py | 5 +++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 725ec50..d85c44e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,7 @@ jobs: BOT_TOKEN: "fake" SIGNING_SECRET: "also_fake" TZ: "US/Eastern" + TESTING: "True" runs-on: ubuntu-latest steps: diff --git a/src/database.py b/src/database.py index fe33124..847effa 100644 --- a/src/database.py +++ b/src/database.py @@ -5,7 +5,12 @@ import sqlite3 from typing import Union -DB_PATH = os.path.abspath(os.environ.get("DB_PATH", "./slack-events-bot.db")) +DB_PATH = os.path.abspath( + os.environ.get( + "DB_PATH", + ":memory:" if os.environ.get("TESTING") == "True" else "./slack-events-bot.db", + ) +) class Connection: diff --git a/tests/conftest.py b/tests/conftest.py index a349940..3496a17 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,8 +18,9 @@ def test_client(): @pytest.fixture(autouse=True) -def clear_db(): - """Clear the database after each test that uses it.""" +def setup_db(): + """Set up and clear the database before each test.""" + database.create_tables() database.clear_db()