Fidelity: callback-answer discipline, message-id/edit/stale lifecycle, Redis test storage (v0.3.0) - #1
Merged
0x216 merged 3 commits intoJul 18, 2026
Conversation
…dis test storage (v0.3.0) Close two production bug classes that slipped a green MockBot suite: 1. Eternal spinner: a callback handler that never calls callback.answer() auto-acked silently. New opt-in strict_answer=True fails the step with CallbackNotAnswered and makes a second answer to the same query a genuine TelegramBadRequest (answer-once). DispatchResult.assert_answered() + .answered/.callback_query_id expose it per-step; MockedSession tracks answered_callbacks. 2. "Screen out of date"/menu freshness: edits now KEEP their message_id (inline edits return True), and MockBot.sent_messages/last_message expose the on-screen history so a test can tap a button on an earlier, now-stale message and reach a handler's stale branch. Also add fake_redis_storage() (optional `redis` extra) — a real aiogram RedisStorage over in-process fakeredis, same serialization/key builder as prod — passed via MockBot(router, storage=...), to surface FSM bugs MemoryStorage hides. Concurrent-update driving (double-tap races) is deferred to a follow-up issue with a contextvar-based design sketch (docs/superpowers/specs/...). All additive/opt-in: existing public API and tests unchanged; strict_answer and storage default to prior behaviour. Suite green, ruff + mypy strict clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UxTypHUUDsvB6coXwEKyaX
…10/3.11 coverage.py 7.11.1 regressed: under `coverage run -m pytest` the process segfaults (SIGSEGV / exit 139) during interpreter-shutdown garbage collection on CPython 3.10 and 3.11 - *after* the suite reports "130 passed" - once the traced object graph includes the v0.3.0 redis test's aiogram + fakeredis objects. Bisected: 7.11.0 clean, 7.11.1+ bad; both the C and pure-python cores crash, so no COVERAGE_CORE setting avoids it, and 3.12/3.13 are unaffected. Coverage numbers are identical on 7.11.0, so the >=90% gate is untouched. Widen the bound once upstream ships a fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UxTypHUUDsvB6coXwEKyaX
aiogram 3.30.0 added the `subscription` update type and the DeleteEphemeralMessage / EditEphemeralMessage* methods; the committed table was generated against 3.29.1, so the coverage-table freshness gate failed. Regenerate via scripts/gen_coverage.py (byte-identical on py3.11 and py3.12). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UxTypHUUDsvB6coXwEKyaX
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
teremok's promise is test green ⇒ works in production. Two bug classes recently slipped a fully-green MockBot suite and only surfaced on a live bot:
callback.answer(). Real Telegram leaves a loading spinner on the button for ~30s; MockBot auto-acked every callback, so the test passed.callback.message.message_id == stored_menu_id(delete-and-resend menus) have a stale branch that a mock minting a fresh carrier id on every tap can't reach — and returning a new id from an edit (Telegram keeps the id) mismodels the stored id itself.Both are pure fidelity gaps, the same kind v0.2.0's validation layer set out to close.
What's in this PR
1. Callback-answer discipline — kills the eternal-spinner class (opt-in:
strict_answer=True)AnswerCallbackQueryfor the same id raises the genuineTelegramBadRequestthe live API returns (query is too old … or query ID is invalid), via the samecheck_responseroute as validation.CallbackNotAnswered.DispatchResult.assert_answered()(+.answered/.callback_query_id) asserts a single step without global strictness;MockedSession.answered_callbacksrecords ids regardless of the flag._StrictAnswerSessionfor.2. Message-id + edit + stale-message lifecycle — kills the menu-freshness class
message_id(an inline-message edit returnsTrue); a fresh send still gets a new id.MockBot.sent_messages/last_messageexpose the on-screen message history, so a test taps a button on an earlier, now-stale message (MockCallbackQuery(message=old_msg, data=...)) and reaches the handler's "screen out of date" branch. Regression test walks: open menu → tap current → bot pushes a new screen → tap the old one → out-of-date branch fires.3a. Redis-backed FSM storage for tests (optional
redisextra)fake_redis_storage()→ a real aiogramRedisStorageover in-process fakeredis (same JSON serialization + key builder as prod, no server). Passed asMockBot(router, storage=...).MemoryStoragehides — e.g. stashing a non-JSON value in state data: green on memory, raises on Redis (test included).3b. Concurrent-update driving — deferred to a follow-up issue
Reliable per-step request attribution under
asyncio.gatherneeds acontextvarstamp threaded fromfeed_updateintoMockedSession.make_request; a naïvegather()would mis-slicerequests[start:]. Design sketch is indocs/superpowers/specs/2026-07-18-…-design.mdand the tracking issue, rather than ship a half-baked version.Compatibility / migration
strict_answerandstorageare new keyword-only options defaulting to prior behaviour;DispatchResultgained two fields with defaults. Existing public API and all pre-existing tests unchanged.mock_bot(router, strict_answer=True)(or assert per-step withresult.assert_answered()). A future major version may flip the default.Checks
test_callback_answer.py,test_message_lifecycle.py,test_redis_storage.py).ruff check .clean,mypy srcstrict clean, coverage 96% (gate ≥90%).🤖 Generated with Claude Code
https://claude.ai/code/session_01UxTypHUUDsvB6coXwEKyaX