From fd7bb1e31a49a05e94c610762ef263b682887673 Mon Sep 17 00:00:00 2001 From: apple050620312 Date: Thu, 10 Sep 2026 14:13:33 +0800 Subject: [PATCH] test: add CI coverage and pin dependencies --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++ cogs/link_fix.py | 13 +++++----- cogs/setup.py | 4 +++- database/models/Event.py | 2 ++ pyproject.toml | 10 ++++++++ requirements-dev.txt | 2 ++ requirements.txt | 4 ++-- src/websites.py | 6 ++--- tests/conftest.py | 6 +++++ tests/test_hot_path_cache.py | 32 +++++++++++++++++++++++++ tests/test_link_parsing.py | 46 ++++++++++++++++++++++++++++++++++++ tests/test_project_files.py | 28 ++++++++++++++++++++++ tests/test_sharding.py | 28 ++++++++++++++++++++++ tests/test_utils.py | 8 +++++++ 14 files changed, 205 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_hot_path_cache.py create mode 100644 tests/test_link_parsing.py create mode 100644 tests/test_project_files.py create mode 100644 tests/test_sharding.py create mode 100644 tests/test_utils.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7b853ce --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" + cache: pip + - name: Install dependencies + run: python -m pip install -r requirements.txt -r requirements-dev.txt + - name: Lint + run: ruff check . + - name: Test + run: pytest diff --git a/cogs/link_fix.py b/cogs/link_fix.py index db8e07b..03c90c5 100644 --- a/cogs/link_fix.py +++ b/cogs/link_fix.py @@ -3,6 +3,7 @@ """ import re +import asyncio from typing import List import discord_markdown_ast_parser as dmap from discord_markdown_ast_parser.parser import NodeType @@ -10,13 +11,13 @@ import os import time -from database.models.Member import * +from database.models.Member import Member from database.models.Role import Role -from database.models.TextChannel import * -from database.models.Guild import * -from database.models.Event import * -from src.websites import * -from src.utils import * +from database.models.TextChannel import GuildMessageableChannel, TextChannel +from database.models.Guild import Guild, OriginalMessage +from database.models.Event import Event +from src.websites import WebsiteLink, websites +from src.utils import Typing, entrypoint_context, group_items, safe_send_coro from src.hot_path_cache import MISSING, filter_cache, guild_cache, webhook_cache from src.runtime import RuntimeBusyError, run_database diff --git a/cogs/setup.py b/cogs/setup.py index 0e21362..989a4d7 100644 --- a/cogs/setup.py +++ b/cogs/setup.py @@ -1,8 +1,10 @@ +import asyncio +import json import logging import aiohttp from src import utils -from database.models.Event import * +from database.models.Event import Event from src.runtime import RuntimeBusyError, run_database import discore diff --git a/database/models/Event.py b/database/models/Event.py index 456c289..9a255b4 100644 --- a/database/models/Event.py +++ b/database/models/Event.py @@ -12,6 +12,8 @@ import discore +__all__ = ('Event',) + _logger = logging.getLogger(__name__) _buffer_limit = max(1000, int(os.getenv('ANALYTICS_BUFFER_LIMIT', '100000'))) _flush_batch_size = max(100, int(os.getenv('ANALYTICS_FLUSH_BATCH_SIZE', '5000'))) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2403ab2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" + +[tool.ruff] +target-version = "py314" +line-length = 120 + +[tool.ruff.lint] +select = ["E9", "F63", "F7", "F82"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..1fb9102 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest ~= 8.4 +ruff ~= 0.12 diff --git a/requirements.txt b/requirements.txt index 1d5b22a..2a686c6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -discore @ git+https://github.com/Kyrela/discore +discore @ git+https://github.com/Kyrela/discore@d266d4683563b1cd03cf3b6e3149bc11b98dd358 python-i18n ~= 0.3.9 psutil ~= 7.2.2 masonite-orm @ git+https://github.com/MasoniteFramework/orm@c4959b4cadfc5207e48ae0f3918d0c1214e25865 # Temporary fix for Masonite ORM until next, hopefully stable, release pymysql ~= 1.1.1 -discord_markdown_ast_parser @ git+https://github.com/Kyrela/discord-markdown-ast-parser +discord_markdown_ast_parser @ git+https://github.com/Kyrela/discord-markdown-ast-parser@9e38d0415a900914edcbe492cbe39323e273d596 aiohttp ~= 3.13.3 diff --git a/src/websites.py b/src/websites.py index 8222410..b226607 100644 --- a/src/websites.py +++ b/src/websites.py @@ -5,10 +5,10 @@ import asyncio import os import re -from typing import Type, Iterable, Callable +from typing import Type, Iterable, Callable, Self -from database.models.Event import * -from database.models.Guild import * +from database.models.Event import Event +from database.models.Guild import EmbedEzView, FxEmbedView, Guild, InstagramView, TiktokView from src import utils import aiohttp diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..662de6e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +import discore + + +def pytest_configure(): + if not discore.config.loaded: + discore.config_init() diff --git a/tests/test_hot_path_cache.py b/tests/test_hot_path_cache.py new file mode 100644 index 0000000..39d5321 --- /dev/null +++ b/tests/test_hot_path_cache.py @@ -0,0 +1,32 @@ +from src import hot_path_cache + + +def test_cache_evicts_least_recently_used_item(): + cache = hot_path_cache.TTLCache(max_size=2, ttl=60) + cache.set('first', 1) + cache.set('second', 2) + cache.set('third', 3) + + assert cache.get('first') is hot_path_cache.MISSING + assert cache.get('second') == 2 + assert cache.get('third') == 3 + + +def test_cache_expires_items(monkeypatch): + now = 100.0 + monkeypatch.setattr(hot_path_cache.time, 'monotonic', lambda: now) + cache = hot_path_cache.TTLCache(max_size=2, ttl=5) + cache.set('key', 'value') + now = 106.0 + + assert cache.get('key') is hot_path_cache.MISSING + + +def test_guild_invalidation_only_removes_matching_keys(): + cache = hot_path_cache.TTLCache(max_size=4, ttl=60) + cache.set((10, 'member'), True) + cache.set((11, 'member'), False) + cache.pop_guild(10) + + assert cache.get((10, 'member')) is hot_path_cache.MISSING + assert cache.get((11, 'member')) is False diff --git a/tests/test_link_parsing.py b/tests/test_link_parsing.py new file mode 100644 index 0000000..3ffc2b6 --- /dev/null +++ b/tests/test_link_parsing.py @@ -0,0 +1,46 @@ +import asyncio + +import discord_markdown_ast_parser as dmap + +from cogs.link_fix import get_embeddable_urls +from database.models.Guild import FxEmbedView +from src.websites import TwitterLink, generate_regex + + +class FakeGuild: + lang = 'en' + + def __init__(self, **settings): + self.settings = settings + + def __getitem__(self, key): + return self.settings[key] + + +def test_markdown_parser_ignores_code_and_preserves_spoilers(): + nodes = dmap.parse('`https://x.com/ignored/status/1` ||https://x.com/user/status/2||') + + assert get_embeddable_urls(nodes) == [('https://x.com/user/status/2', True)] + + +def test_generated_route_requires_the_whole_url_to_match(): + regex = generate_regex('example.com', '/:username/post/:id') + + match = regex.fullmatch('https://www.example.com/alice/post/42?ref=test') + assert match is not None + assert match['username'] == 'alice' + assert match['id'] == '42' + assert regex.fullmatch('https://evil.example/example.com/alice/post/42') is None + + +def test_twitter_link_renders_expected_proxy(): + guild = FakeGuild( + twitter=True, + twitter_view=FxEmbedView.NORMAL, + twitter_tr=False, + ) + link = TwitterLink(guild, 'https://x.com/alice/status/42') + + fixed_url, label = asyncio.run(link.get_fixed_url()) + assert fixed_url == 'https://fxtwitter.com/i/status/42' + assert label == 'FxTwitter' diff --git a/tests/test_project_files.py b/tests/test_project_files.py new file mode 100644 index 0000000..626d676 --- /dev/null +++ b/tests/test_project_files.py @@ -0,0 +1,28 @@ +import importlib.util +from pathlib import Path + +import yaml + + +ROOT = Path(__file__).parents[1] + + +def test_all_locale_files_are_valid_yaml(): + for locale_file in (ROOT / 'locales').glob('*.yml'): + with locale_file.open(encoding='utf-8') as stream: + assert isinstance(yaml.safe_load(stream), dict), locale_file + + +def test_integrity_migration_imports(): + migration_path = ROOT / 'database/migrations/2026_09_10_000000_add_integrity_indexes.py' + spec = importlib.util.spec_from_file_location('integrity_migration', migration_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + +def test_compose_uses_one_shot_migration_and_private_database(): + with (ROOT / 'docker-compose.example.yml').open(encoding='utf-8') as stream: + compose = yaml.safe_load(stream) + + assert compose['services']['bot']['depends_on']['migrate']['condition'] == 'service_completed_successfully' + assert 'ports' not in compose['services']['db'] diff --git a/tests/test_sharding.py b/tests/test_sharding.py new file mode 100644 index 0000000..996cc77 --- /dev/null +++ b/tests/test_sharding.py @@ -0,0 +1,28 @@ +import pytest + +from src.sharding import shard_options + + +def test_sharding_is_automatic_without_assignment(): + assert shard_options({}) == {} + + +def test_shard_assignment_is_parsed_and_trimmed(): + assert shard_options({'SHARD_COUNT': '50', 'SHARD_IDS': '0, 7,49'}) == { + 'shard_count': 50, + 'shard_ids': [0, 7, 49], + } + + +@pytest.mark.parametrize( + 'environment', + [ + {'SHARD_IDS': '1'}, + {'SHARD_COUNT': '0'}, + {'SHARD_COUNT': '2', 'SHARD_IDS': '2'}, + {'SHARD_COUNT': '2', 'SHARD_IDS': '1,1'}, + ], +) +def test_invalid_shard_assignment_fails_fast(environment): + with pytest.raises((RuntimeError, ValueError)): + shard_options(environment) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..69001f8 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,8 @@ +from src.utils import group_items + + +def test_group_items_never_exceeds_limit_when_items_fit(): + groups = group_items(['abc', 'de', 'fgh'], max_group_size=6, sep='|') + + assert groups == [('abc|de', ['abc', 'de']), ('fgh', ['fgh'])] + assert all(len(text) <= 6 for text, _ in groups)