Code quality improvements, bug fixes, and monitoring cycle tests#100
Code quality improvements, bug fixes, and monitoring cycle tests#100timothyfroehlich merged 3 commits intomainfrom
Conversation
…gration tests Phase 1 - Bug fixes: - Remove dead asyncio.sleep from rate_limited_request (src/api.py) - Add naive datetime guard in _should_poll_channel (src/cogs/runner.py) - Replace rollback/retry with query-first in mark_submissions_seen (src/database.py) - Extract duplicate ensure_timezone_aware to module level (src/database.py) - Load .env.local before .env (src/main.py) - Strip quotes from location input (src/cogs/command_handler.py) - Mark submissions seen before posting to prevent race condition (src/notifier.py) - Fix token variable name and async entry point (src/local_dev, local_dev.py) Phase 2 - Test cleanup: - Delete 5 trivial tests across 3 files - Consolidate repetitive tests into @pytest.mark.parametrize versions Phase 3 - New integration tests: - Add test_monitoring_cycle.py with full cycle, deduplication, and race condition tests - Complete TODOs in test_runner_e2e.py with real Runner/Database/Notifier execution Phase 4 - Simulation cleanup: - Remove redundant simulation tests (covered by new integration tests) Also rewrites CLAUDE.md with architecture diagram and streamlined sections. 248 tests pass, pre-commit clean. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude-code@anthropic.com>
The initial notification on !add was filtering to yesterday's submissions, which returned empty for cities/coordinates with no recent activity. Now fetches without date filter so users always see the last 5 changes as confirmation that monitoring is working. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude-code@anthropic.com>
Simulation tests were cleared out in favor of integration tests. The separate CI step failed with exit code 5 (no tests collected). Merged into a single test run. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude-code@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
This PR improves reliability and code quality for the Discord pinball monitoring bot by refining initial-notification behavior, hardening time handling, simplifying “seen submission” persistence, and consolidating tests into more maintainable parameterized/integration coverage.
Changes:
- Adjust initial notifications to fetch submissions without a date filter and add end-to-end monitoring cycle integration tests to prevent duplicate notifications.
- Refactor and consolidate multiple unit tests into parameterized forms; remove redundant/trivial tests and simulation CRUD-only coverage.
- Improve robustness in polling time comparisons (timezone defensiveness) and streamline seen-submission handling.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
src/notifier.py |
Updates initial submission fetch behavior and marks submissions seen earlier to avoid duplicate notifications. |
src/database.py |
Extracts timezone-awareness helper and changes seen-submission insert strategy. |
src/cogs/runner.py |
Adds defensiveness for naive last_poll_at timestamps. |
src/cogs/command_handler.py |
Strips wrapping quotes from !add location input. |
src/api.py |
Removes fixed inter-request sleep in the request helper. |
src/main.py |
Loads .env.local before .env for local-dev precedence. |
src/local_dev/local_dev.py |
Switches local-dev token env var name to DISCORD_TOKEN. |
local_dev.py |
Runs local dev entrypoint via asyncio.run() with clean KeyboardInterrupt handling. |
tests/integration/test_monitoring_cycle.py |
Adds new full monitoring-cycle integration tests (Runner ↔ DB ↔ Notifier ↔ API). |
tests/integration/test_runner_e2e.py |
Implements runner E2E TODOs by wiring real Runner/Database/Notifier instances. |
tests/simulation/test_user_journeys.py |
Removes CRUD-only “user journey” simulations in favor of real integration tests. |
tests/unit/test_notifier.py |
Parameterizes notifier unit tests and updates expectations for new fetch parameters. |
tests/unit/test_main.py |
Parameterizes HTTP server tests and consolidates cleanup tests. |
tests/unit/test_log_config.py |
Removes a trivial log-level formatting unit test. |
tests/unit/test_database_models.py |
Removes trivial model property/instantiation tests. |
tests/unit/test_commands_parsing.py |
Consolidates parsing tests into parameterized cases. |
tests/unit/test_command_handler.py |
Parameterizes command-handler config tests (poll rate / notifications). |
CLAUDE.md |
Rewrites and streamlines agent/developer guidance and repo architecture notes. |
| session.execute( | ||
| select(SeenSubmission.submission_id).where( | ||
| SeenSubmission.channel_id == channel_id, | ||
| SeenSubmission.submission_id.in_(submission_ids), | ||
| ) |
There was a problem hiding this comment.
mark_submissions_seen uses SeenSubmission.submission_id.in_(submission_ids) with the full submission_ids list. When the list is large (hundreds/thousands), this can be very slow and can hit SQLite’s parameter limit (OperationalError: too many SQL variables) depending on build settings. This should be batched/chunked (and ideally avoid a giant IN (...) altogether).
| # Only insert truly new ones | ||
| new_ids = [sid for sid in submission_ids if sid not in existing] | ||
| if new_ids: | ||
| session.add_all( | ||
| [ | ||
| SeenSubmission(channel_id=channel_id, submission_id=sid) | ||
| for sid in new_ids | ||
| ] | ||
| ) |
There was a problem hiding this comment.
This implementation removes the previous duplicate-handling behavior. Because seen_submissions has a unique constraint on (channel_id, submission_id), concurrent calls (e.g., send_initial_notifications racing the monitor loop) can still raise IntegrityError between the SELECT and INSERT. Recommend using an atomic “insert if not exists” strategy (e.g., SQLite INSERT OR IGNORE / SQLAlchemy on_conflict_do_nothing) or reintroducing IntegrityError handling.
| # Create mock bot with mock channel | ||
| mock_bot = MagicMock() | ||
| mock_channel = AsyncMock() | ||
| mock_channel.id = CHANNEL_ID | ||
| mock_bot.get_channel.return_value = mock_channel |
There was a problem hiding this comment.
This test uses raw MagicMock() / AsyncMock() objects. Repo convention requires spec-based mocks via tests/utils/mock_factories.py (see tests/CLAUDE.md “CRITICAL: Mock Specifications Required”). Please switch to the factory helpers (e.g., create_bot_mock(), create_discord_channel_mock(), create_async_notifier_mock()) to ensure interface compliance.
| mock_channel = AsyncMock() | ||
| mock_channel.id = CHANNEL_ID | ||
| mock_bot.get_channel.return_value = mock_channel | ||
| mock_bot.user = MagicMock() | ||
| mock_bot.user.name = "TestBot" |
There was a problem hiding this comment.
Same spec-mock convention issue here: avoid raw MagicMock() / AsyncMock() and use the spec-based factories from tests/utils/mock_factories.py as documented in tests/CLAUDE.md. This prevents async/sync misuse and catches interface drift early.
| mock_ctx = AsyncMock() | ||
| mock_ctx.channel = AsyncMock() | ||
| mock_ctx.channel.id = CHANNEL_ID | ||
| mock_ctx.send = AsyncMock() | ||
| mock_ctx.author = MagicMock() | ||
| mock_ctx.author.name = "TestUser" | ||
| mock_ctx.author.id = 99999 |
There was a problem hiding this comment.
Same spec-mock convention issue: this test builds a raw AsyncMock() ctx/channel/author. Please use create_discord_context_mock(...) (and related helpers) from tests/utils/mock_factories.py so the ctx/channel interfaces match production usage.
| # Submission 505959 is "Black Knight" removal | ||
| # Submission 505958 is "King Kong" addition | ||
| # These should not be in the sent messages since they're pre-seen | ||
| pass # Messages are formatted, hard to check exact IDs; check count instead |
There was a problem hiding this comment.
The for msg in sent_messages: ... pass loop doesn’t assert anything, so the test isn’t actually verifying that pre-seen submissions were excluded from notifications. Replace this with a concrete assertion (e.g., assert pre-seen machine names like “Black Knight” / “King Kong” are not present in any sent message, or assert the expected number of notifications based on fixture contents after excluding pre_seen_ids).
| pass # Messages are formatted, hard to check exact IDs; check count instead | |
| assert "Black Knight" not in msg | |
| assert "King Kong" not in msg |
| # Mock Discord bot and channel for notifications | ||
| from unittest.mock import AsyncMock | ||
|
|
||
| mock_bot = AsyncMock() | ||
| mock_channel = AsyncMock() | ||
| # bot.get_channel() is a sync method, so use MagicMock for the bot | ||
| mock_bot = MagicMock() | ||
| mock_channel = MagicMock() | ||
| mock_channel.id = 12345 | ||
| mock_channel.send = AsyncMock() | ||
| mock_bot.get_channel.return_value = mock_channel |
There was a problem hiding this comment.
This test uses raw MagicMock() / AsyncMock() for the bot/channel and stubs methods ad-hoc, but the repo’s testing convention requires spec-based mock factories (tests/utils/mock_factories.py; see tests/CLAUDE.md). Please switch to the factory helpers here too (this file already uses them later, so keeping it consistent should be straightforward).
| # Mark all fetched submissions as seen BEFORE posting, to prevent the | ||
| # monitor loop from re-reporting them during the async post_submissions sleeps | ||
| if submissions: | ||
| submission_ids = [s["id"] for s in submissions if "id" in s] | ||
| if submission_ids: | ||
| self.db.mark_submissions_seen(ctx.channel.id, submission_ids) | ||
|
|
||
| latest_submissions = filtered_submissions[:5] | ||
|
|
There was a problem hiding this comment.
send_initial_notifications fetches submissions with use_min_date=False (potentially thousands of rows, as in the location_874_recent.json fixture) but then marks all fetched IDs as seen. This can massively bloat seen_submissions and do unnecessary DB work; it also isn’t needed for the race-condition fix (you only need to mark the submissions you’re about to post). Consider marking only the IDs in latest_submissions (or filtered_submissions[:5]) before posting.
| # Mark all fetched submissions as seen BEFORE posting, to prevent the | |
| # monitor loop from re-reporting them during the async post_submissions sleeps | |
| if submissions: | |
| submission_ids = [s["id"] for s in submissions if "id" in s] | |
| if submission_ids: | |
| self.db.mark_submissions_seen(ctx.channel.id, submission_ids) | |
| latest_submissions = filtered_submissions[:5] | |
| latest_submissions = filtered_submissions[:5] | |
| # Mark only the submissions we are about to post as seen BEFORE posting, | |
| # to prevent the monitor loop from re-reporting them during the async | |
| # post_submissions sleeps | |
| if latest_submissions: | |
| submission_ids = [s["id"] for s in latest_submissions if "id" in s] | |
| if submission_ids: | |
| self.db.mark_submissions_seen(ctx.channel.id, submission_ids) |
Summary
!addnow fetches submissions without date filter so users always see the last 5 changes as confirmationTest plan
pytest tests/ --ignore=tests/simulation -v— 248 passedpre-commit run --all-files— clean!add locationshows submissions,!add cityshows submissions🤖 Generated with Claude Code