diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 400d5a6..d2b8561 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,9 +32,46 @@ jobs: . venv/bin/activate make lint - build-docker-image: + test: needs: build runs-on: ubuntu-latest + services: + postgres: + image: postgres:13.9-alpine + env: + POSTGRES_HOST_AUTH_METHOD: trust + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-python@v4 + id: setup-python + with: + python-version-file: '.python-version' + + - uses: actions/cache@v3 + with: + path: | + venv + key: ${{ runner.os }}-venv-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/*requirements.txt') }} + + - name: Run the tests + env: + DATABASE_URL: postgres://postgres@localhost:5432/postgres + run: | + . venv/bin/activate + make test + + build-docker-image: + needs: test + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 diff --git a/Makefile b/Makefile index 54f0277..db39db1 100644 --- a/Makefile +++ b/Makefile @@ -17,5 +17,10 @@ lint: fmt: isort . +test: + pytest --dead-fixtures + pytest -x + dev: watchmedo auto-restart --patterns '*.py' python bot.py + diff --git a/dev-requirements.txt b/dev-requirements.txt index b7c5d86..67834d0 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -39,7 +39,9 @@ emoji==1.7.0 eradicate==2.3.0 # via flake8-eradicate exceptiongroup==1.1.2 - # via anyio + # via + # anyio + # pytest executing==1.2.0 # via stack-data flake8==6.0.0 @@ -106,6 +108,8 @@ idna==3.4 # via # anyio # httpx +iniconfig==2.0.0 + # via pytest ipython==8.14.0 # via channel-discussion-antispam-bot (pyproject.toml) isort==5.12.0 @@ -122,6 +126,8 @@ mypy==1.4.1 # via channel-discussion-antispam-bot (pyproject.toml) mypy-extensions==1.0.0 # via mypy +packaging==23.1 + # via pytest parso==0.8.3 # via jedi peewee==3.16.2 @@ -130,6 +136,8 @@ pexpect==4.8.0 # via ipython pickleshare==0.7.5 # via ipython +pluggy==1.2.0 + # via pytest prompt-toolkit==3.0.39 # via ipython psycopg2-binary==2.9.6 @@ -148,6 +156,20 @@ pyflakes==3.0.1 # via flake8 pygments==2.15.1 # via ipython +pytest==7.4.0 + # via + # pytest-deadfixtures + # pytest-env + # pytest-mock + # pytest-randomly +pytest-deadfixtures==2.2.1 + # via channel-discussion-antispam-bot (pyproject.toml) +pytest-env==0.8.2 + # via channel-discussion-antispam-bot (pyproject.toml) +pytest-mock==3.11.1 + # via channel-discussion-antispam-bot (pyproject.toml) +pytest-randomly==3.12.0 + # via channel-discussion-antispam-bot (pyproject.toml) python-dotenv==1.0.0 # via channel-discussion-antispam-bot (pyproject.toml) python-telegram-bot[webhooks]==20.3 @@ -171,6 +193,7 @@ tomli==2.0.1 # via # flake8-pyproject # mypy + # pytest tornado==6.3.2 # via python-telegram-bot traitlets==5.9.0 diff --git a/filters.py b/filters.py index 957ec51..10ea573 100644 --- a/filters.py +++ b/filters.py @@ -13,7 +13,6 @@ class HasNoValidPreviousMessages(MessageFilter): def filter(self, message: Message) -> bool: if not DB_ENABLED() or message.from_user is None: return True - return self.has_no_valid_previous_messages(user_id=message.from_user.id, chat_id=message.chat_id) @classmethod @@ -61,8 +60,7 @@ def filter(self, message: Message) -> bool: if message.text is None: return False - entities_types = set([entity.type for entity in message.entities]) - return len(entities_types.intersection({'url', 'text_link'})) != 0 + return any(entity.type in ('url', 'text_link') for entity in message.entities) class ContainsThreeOrMoreEmojies(MessageFilter): diff --git a/models.py b/models.py index 891e185..b1ef65c 100644 --- a/models.py +++ b/models.py @@ -18,10 +18,16 @@ class LogEntry(pw.Model): class Meta: database = db indexes = ( - ('chat_id', 'message_id'), - True, + ( + ('chat_id', 'message_id'), + True, + ), ) def create_tables(): db.create_tables([LogEntry]) + + +def drop_tables(): + db.drop_tables([LogEntry]) diff --git a/pyproject.toml b/pyproject.toml index e01c8ac..1d4d74d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,11 @@ dev = [ "flake8-variables-names", "flake8-walrus", "flake8-pyproject", + + "pytest-deadfixtures", + "pytest-mock", + "pytest-randomly", + "pytest-env", ] @@ -75,3 +80,9 @@ exclude = [ line_length = 160 known_standard_library = ["typing"] multi_line_output = 4 + +[tool.pytest.ini_options] +python_files = ["test*.py"] +env = [ + "DATABASE_URL=postgres://postgres@localhost:5432/postgres" +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a55b636 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +import pytest + +from models import create_tables, db, drop_tables + + +@pytest.fixture(scope="session") +def test_db(): + create_tables() + + yield db + + drop_tables() + + +@pytest.fixture(scope='function', autouse=True) +def _rollback_transactions(test_db): + test_db.begin() + + yield + + test_db.rollback() + +@pytest.fixture +def mock_message(mocker): + return mocker.patch("telegram.Message", autospec=True).return_value diff --git a/tests/tests_filters/__init__.py b/tests/tests_filters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/tests_filters/conftest.py b/tests/tests_filters/conftest.py new file mode 100644 index 0000000..1bdc663 --- /dev/null +++ b/tests/tests_filters/conftest.py @@ -0,0 +1,8 @@ +from typing import Callable + +import pytest + + +@pytest.fixture +def do_filter(filter_obj) -> Callable[[], bool]: + return lambda message: filter_obj.filter(message) diff --git a/tests/tests_filters/test_chat_message_only.py b/tests/tests_filters/test_chat_message_only.py new file mode 100644 index 0000000..64a0a69 --- /dev/null +++ b/tests/tests_filters/test_chat_message_only.py @@ -0,0 +1,20 @@ +import pytest + +from filters import ChatMessageOnly + + +@pytest.fixture(scope="session") +def filter_obj(): + return ChatMessageOnly() + + +def test_false_if_forwarded(do_filter, mock_message): + mock_message.forward_from_message_id = "ordinary-id-yep" + + assert do_filter(mock_message) is False + + +def test_true_if_not_forwarded(do_filter, mock_message): + mock_message.forward_from_message_id = None + + assert do_filter(mock_message) is True diff --git a/tests/tests_filters/test_cointains_three_or_more_emojis.py b/tests/tests_filters/test_cointains_three_or_more_emojis.py new file mode 100644 index 0000000..dde8071 --- /dev/null +++ b/tests/tests_filters/test_cointains_three_or_more_emojis.py @@ -0,0 +1,48 @@ +import pytest + +from filters import ContainsThreeOrMoreEmojies + + +@pytest.fixture +def message(mock_message): + mock_message.text = None + return mock_message + + +@pytest.fixture(scope="session") +def filter_obj(): + return ContainsThreeOrMoreEmojies() + + +def test_false_if_empty_message(do_filter, message): + assert do_filter(message) is False + + +@pytest.mark.parametrize( + "text", + [ + "Shalom πŸ‘‹πŸΎ", + "Ou ui πŸ‘€πŸ™ƒ", + "No emojis actually", + "🐍", + " ", + ] +) +def test_false_if_less_than_3_emojis(do_filter, message, text): + message.text = text + + assert do_filter(message) is False + + +@pytest.mark.parametrize( + "text", + [ + "Shalom πŸ‘‹πŸΎπŸ‘€πŸ™ƒ", + "πŸ˜…πŸ˜ŽπŸ§‘πŸΏβ€πŸ¦±πŸ‘¨β€πŸ‘¨β€πŸ‘§β€πŸ‘§", + "πŸ˜…πŸ˜ŽπŸ§‘πŸΏβ€πŸ¦±πŸ‘¨β€πŸ‘¨β€πŸ‘§β€πŸ‘§πŸ some text πŸ‘‹πŸΎπŸ‘€πŸ™ƒ", + ] +) +def test_true_if_more_than_2_emojis(do_filter, message, text): + message.text = text + + assert do_filter(message) is True diff --git a/tests/tests_filters/test_contains_link.py b/tests/tests_filters/test_contains_link.py new file mode 100644 index 0000000..9323aee --- /dev/null +++ b/tests/tests_filters/test_contains_link.py @@ -0,0 +1,62 @@ +import pytest + +from filters import ContainsLink + + +class FakeMessageEntity: + def __init__(self, type: str): + self.type = type + + +@pytest.fixture +def mock_message_entity(mocker): + return lambda type_str: FakeMessageEntity(type_str) + + +@pytest.fixture +def message(mock_message, mock_message_entity): + # To see all possible types look at telegram.MessageEntity Attributes + message.text = "I'm not empty inside" + code = mock_message_entity("code") + phone_number = mock_message_entity("phone_number") + mock_message.entities = [code, phone_number] + return mock_message + + +@pytest.fixture(scope="session") +def filter_obj(): + return ContainsLink() + + +def test_false_if_no_links_message(do_filter, message): + assert do_filter(message) is False + + +@pytest.mark.parametrize( + "link_type", + [ + "url", + "text_link", + ] +) +def test_true_if_has_link(do_filter, message, mock_message_entity, link_type): + message_entity = mock_message_entity(link_type) + message.entities.append(message_entity) + + assert do_filter(message) is True + + +@pytest.mark.parametrize( + "link_types", + [ + ["text_link", "url"], + ["text_link", "text_link"], + ["url", "url"], + ] +) +def test_true_if_has_many_links(do_filter, message, mock_message_entity, link_types): + for link_type in link_types: + message_entity = mock_message_entity(link_type) + message.entities.append(message_entity) + + assert do_filter(message) is True diff --git a/tests/tests_filters/test_contains_tg_contact.py b/tests/tests_filters/test_contains_tg_contact.py new file mode 100644 index 0000000..6a7cb95 --- /dev/null +++ b/tests/tests_filters/test_contains_tg_contact.py @@ -0,0 +1,46 @@ +import pytest + +from filters import ContainsTelegramContact + + +@pytest.fixture +def message(mock_message): + message.text = "Ordinary text" + return mock_message + + +@pytest.fixture(scope="session") +def filter_obj(): + return ContainsTelegramContact() + + +def test_false_if_no_text_message(do_filter, message): + message.text = None + assert do_filter(message) is False + + +@pytest.mark.parametrize( + "text", + [ + "Hello there!", + "OMG look at my email omg@bbq.wtf", + "sobaka@sobaka", + ] +) +def test_false_if_no_contact(do_filter, message, text): + message.text = text + + assert do_filter(message) is False + + +@pytest.mark.parametrize( + "text", + [ + "write me a message @bbqomg", + "@contact_me", + ] +) +def test_true_if_contact(do_filter, message, text): + message.text = text + + assert do_filter(message) is True diff --git a/tests/tests_filters/test_has_no_valid_previous_messages.py b/tests/tests_filters/test_has_no_valid_previous_messages.py new file mode 100644 index 0000000..739f031 --- /dev/null +++ b/tests/tests_filters/test_has_no_valid_previous_messages.py @@ -0,0 +1,90 @@ +import pytest +import random + +from filters import HasNoValidPreviousMessages +from models import LogEntry + +CHAT_ID = 1 + + +def create_log_message(user_id: int, chat_id: int = CHAT_ID, action: str = '', message_id: int = random.randint(1, 9999)): + return LogEntry.create( + user_id=user_id, + chat_id=chat_id, + message_id=message_id, + text='meh', + meta={'tags': ["ou"]}, + raw={'text': 'meh'}, + action=action, + ) + + +@pytest.fixture +def user(): + class FakeUser: + def __init__(self, id: int): + self.id = id + + return FakeUser(4815162342) + + +@pytest.fixture +def message(mock_message, user): + mock_message.from_user = user + mock_message.chat_id = CHAT_ID + return mock_message + + +@pytest.fixture(scope="session") +def filter_obj(): + return HasNoValidPreviousMessages() + + +@pytest.fixture +def valid_messages(user, filter_obj): + message_id = 1 + for _ in range(filter_obj.MIN_PREVIOUS_MESSAGES_COUNT): + create_log_message(user_id=user.id, message_id=message_id) + message_id += 1 + + +def test_true_if_no_valid_messages(do_filter, message): + assert do_filter(message) is True + + +def test_true_if_not_from_user(do_filter, message): + message.from_user = None + + assert do_filter(message) is True + + +def test_true_if_db_disabled(do_filter, message, mocker): + mocker.patch("helpers.DB_ENABLED", return_value=False) + + assert do_filter(message) is True + + +def test_true_if_has_not_enough_valid_messages(do_filter, message, valid_messages): + LogEntry.get(LogEntry.message_id == 1).delete_instance() + + assert do_filter(message) is True + + +@pytest.mark.parametrize( + ("attribute", "value"), + [ + ("action", "delete"), + ("chat_id", 4815), + ("user_id", 9911), + ] +) +def test_true_if_user_has_not_enough_valid_messages(do_filter, message, valid_messages, attribute, value): + log_entry = LogEntry.get(LogEntry.message_id == 1) + setattr(log_entry, attribute, value) + log_entry.save() + + assert do_filter(message) is True + + +def test_false_if_has_valid_messages(do_filter, message, valid_messages): + assert do_filter(message) is False diff --git a/tests/tests_filters/test_is_media.py b/tests/tests_filters/test_is_media.py new file mode 100644 index 0000000..c7103c8 --- /dev/null +++ b/tests/tests_filters/test_is_media.py @@ -0,0 +1,50 @@ +import pytest + +from filters import IsMedia + + +@pytest.fixture +def message(mock_message): + mock_message.photo = [] + mock_message.document = None + mock_message.audio = None + mock_message.voice = None + mock_message.video_note = None + return mock_message + + +@pytest.fixture(scope="session") +def filter_obj(): + return IsMedia() + + +def test_false_if_empty_message(do_filter, message): + assert do_filter(message) is False + + +@pytest.mark.parametrize( + "photo", + [ + "http://photo.com/", + ["http://localhost/photo", "some-id-like-123"] + ] +) +def test_true_if_has_photos(do_filter, message, photo): + message.photo.append(photo) + + assert do_filter(message) is True + + +@pytest.mark.parametrize( + "attribute", + [ + "document", + "audio", + "voice", + "video_note", + ] +) +def test_true_if_has_media_attr(do_filter, message, attribute): + setattr(message, attribute, "Here we are born to be kings") + + assert do_filter(message) is True diff --git a/tests/tests_filters/test_is_message_behalf_of_chat.py b/tests/tests_filters/test_is_message_behalf_of_chat.py new file mode 100644 index 0000000..67e40ad --- /dev/null +++ b/tests/tests_filters/test_is_message_behalf_of_chat.py @@ -0,0 +1,24 @@ +import pytest + +from filters import IsMessageOnBehalfOfChat + + +@pytest.fixture +def message(mock_message): + mock_message.sender_chat = None + return mock_message + + +@pytest.fixture(scope="session") +def filter_obj(): + return IsMessageOnBehalfOfChat() + + +def test_false_if_no_sender_chat(do_filter, message): + assert do_filter(message) is False + + +def test_true_if_sender_chat(do_filter, message): + message.sender_chat = "very-suspicious-id" + + assert do_filter(message) is True