End-to-end suite
The real end-to-end suite: gateway and sandbox as two processes over HTTP
docs/SANDBOX.md describes a workbench a person drives by hand. CB_QA_SANDBOX=1
(qa/sandbox_harness.py) drives the acceptance suite through tg_sandbox's
store, but it feeds updates straight into the aiogram dispatcher in-process and
only mirrors them into the sandbox — tg_sandbox.control_api is never called
and getUpdates polling never runs. Neither one drives the gateway through
HTTP end to end.
qa/e2e/ closes that gap. It starts the real, unmodified tg_sandbox.app:app
and cb_gateway.main:app as two subprocesses, wired exactly as
docs/SANDBOX.md describes (CB_TELEGRAM_API_BASE + CB_TELEGRAM_INGEST=polling),
and drives every scenario purely by calling the sandbox's /api/... control
surface over real HTTP — the same surface a human clicks through in the web
UI. Every assertion reads back GET /api/state: the chat transcript and,
more importantly, the api_calls log — the only place deleteMessage,
restrictChatMember, banChatMember and answerCallbackQuery are visible at
all.
How this differs from the other two suites
cb.py test (qa/*.py) | CB_QA_SANDBOX=1 | cb.py test-e2e (qa/e2e/) | |
|---|---|---|---|
| Telegram fake | qa/mock_telegram.py, in-process | tg_sandbox, in-process | tg_sandbox, a real subprocess on a real port |
| Gateway | cb_gateway.main.dp.feed_update() called directly | same — feed_update() called directly | a real cb_gateway.main:app subprocess, actually polling |
| Transport | none (Python call) | none (Python call); the update is only mirrored into the store | real HTTP both ways |
| Infra | none required | none required | Postgres + Valkey, already up (cb.py up) |
| Speed | fast (the whole suite) | fast | slow (process startup, real polling) |
| Question it answers | "is the behaviour still correct" | "what does that behaviour look like" | "does the shipped gateway, talking real HTTP to a real Bot-API-shaped server, actually do the right thing" |
None of the three replace either of the other two. cb.py test is the CI
gate and must never slow down or flake because this suite exists — see
"Why it's opt-in" below.
Running it
python scripts/cb.py up # Postgres + Valkey (podman/docker compose)
python scripts/cb.py test-e2eThat's it — the task itself sets CB_RUN_E2E=1, starts a sandbox and a
gateway subprocess per test session, and tears both down afterward. A plain
python scripts/cb.py test (or pytest qa/e2e with no marker) skips every
test in this package instantly; see "Why it's opt-in" below.
Environment overrides (rarely needed):
| Variable | Default | Purpose |
|---|---|---|
CB_E2E_PG_DSN (falls back to CB_PG_DSN) | postgresql://cookiebot:cookiebot@localhost:5432/cookiebot | the shared Postgres this suite reads/writes groups/group_configs rows in |
CB_E2E_REDIS_DSN | redis://localhost:6379/14 | a Valkey database index of its own — never 0 (dev), never 15 (qa/'s own tests) |
CB_E2E_SANDBOX_DB | sandbox-e2e.duckdb at the repo root | where the run is recorded, so it can be opened afterwards — see "Reading the run afterwards" |
If Postgres or Valkey is unreachable, the suite skips cleanly (pytest.skip,
same pattern as qa/conftest.py's database/valkey fixtures) — it never
fails just because infra isn't up.
What it needs, and what it does per run
- Two subprocesses.
tg_sandbox.app:appon a free port with its own throwaway DuckDB file (never the developer'ssandbox.duckdb), and the realcb_gateway.main:appon another free port, pointed at the sandbox (CB_TELEGRAM_API_BASE) withCB_TELEGRAM_INGEST=polling. Readiness is polled (GET /healthz,GET /readyz), never a fixed sleep; a process that dies before becoming ready fails loudly with its own log tail. - A one-time database warm-up. The first array-typed query on a fresh
asyncpg connection pays a real, multi-second Citus catalog-introspection
cost on an emulated container (the same cost
qa/conftest.py'sdatabasefixture works around with a 60s command timeout).qa/e2e/conftest.py'sgateway_processfixture pays it once, in setup, with a generous timeout and a throwaway group, so every real scenario's ownwait_forbudgets for what it is actually testing. - One fresh group per test. A sandbox chat via
POST /api/chats, backed by agroupsrow in the shared Postgres (every handler's config/rules/ admin/captcha lookups need it — every dependent table hasFOREIGN KEY (group_id) REFERENCES groups (group_id) ON DELETE CASCADE). Titlede2e:<test node id>so a human can tell which test owns which row. Postgres is shared across concurrent runs and CI workers in a way the sandbox's own throwaway DuckDB file is not, so cleanup is real:DELETE FROM groups(cascades everything) runs in afinally, and setup defensively deletes any same-id leftover from an earlier, killed run first (the sandbox's chat-id counter restarts at the same values every session; Postgres does not). - Bounded polling, never a blind sleep.
qa/e2e/client.py:wait_forpollsGET /api/stateon a short interval up to an explicit timeout and raises with the tail ofapi_callson failure — "the bot never answered X within Ns; last api_calls: [...]" — neversleep(3)and hope.
Why it's opt-in
scripts/cb.py test runs pytest -q -m "not integration" with no path
argument, so qa/e2e/'s modules get collected by pyproject.toml's
testpaths like everything else. qa/e2e/conftest.py's
pytest_collection_modifyitems hook skips every item carrying
pytest.mark.e2e unless CB_RUN_E2E=1 is set — which only
scripts/cb.py test-e2e does. That keeps the fast gate fast: collection still
happens (cheap: no subprocess, no network), but every fixture is skipped
before it can run, so cb.py test/cb.py test-all/cb.py check never pay
for a subprocess, let alone two, on this suite's account.
Reading the run afterwards
The suite is not only a pass/fail gate — it is a recording. Every test opens a scenario in the sandbox before its first action and closes it with the test's own outcome, and every message and Bot API call in between is tagged with it. So when the run finishes you can open the whole thing in the web UI and step through what the bot actually did, one test at a time:
TG_SANDBOX_DB=sandbox-e2e.duckdb python scripts/cb.py sandbox # :8083
python scripts/cb.py sandbox-web # :3001Then pick a test from the scenario filter. You get that test's chat traffic,
that test's API calls (deleteMessage, restrictChatMember, banChatMember
— the ones no chat window shows), and what the test was for: its name, its
docstring, its file, the group id it owned, and, when it failed, the traceback
as a note.
No gateway is needed to read a recording — the bot is not going to answer anything, the answers are already in the file. Start one only if you want to keep driving the world by hand from where the suite left off.
The file is wiped at the start of each run, not the end: a recording that deleted itself on exit would be missing exactly when you want it. The run's path is printed in the pytest summary.
What each scenario carries:
| Field | From |
|---|---|
| name | the test function's name |
| description | its docstring |
| tags | its module, minus the test_ prefix (captcha, rules, ...) |
| metadata | nodeid, source file, pytest markers, and the group_id the test owned |
| status | passed / failed / skipped, including a failure during fixture setup |
| notes | the failure traceback, when there is one |
A person doing manual UAT can open a scenario the same way from the UI, and record what they were checking and what they concluded next to the bot's own behaviour.
Scenarios covered
Picked from what the progress board marks done and green, one file
per feature area. Every scenario below runs twice — once for an en
group, once for a pt group — because qa/e2e/conftest.py's lang fixture
is parametrized and group_id/captcha_group_id both depend on it, so any
test using either is pulled into the same parametrization automatically.
en was the only language this suite ever exercised until that fixture
existed, even though pt groups are the majority of the bot's real traffic;
test_x[en]/test_x[pt] node ids (and matching sandbox scenario tags) are
how the two runs stay distinguishable, in pytest's own output and in the
recording:
| File | Scenario |
|---|---|
test_privacy_and_commands.py | /privacy and /commands round trip |
test_rules.py | /rules with none configured; the /newrules two-step reply flow, admin and non-admin |
test_config_menu.py | /configurar denies a non-admin (+ tutorial video); an anonymous admin is not denied — the v1 defect the port fixes; an admin who opened a DM gets the menu there and its buttons answer |
test_captcha.py | a newcomer is challenged, the challenge replies to the join message, and the approve button answers |
test_calladms.py | /adm -> press the confirm button via /api/chats/{id}/callback -> answerCallbackQuery + deleteMessage + the admin ping |
test_join_chain.py | a plain self-join falls through to the welcome message; a doomlisted name is banned before welcome ever runs |
test_stickerspam.py | sticker flood warns at the limit, deletes past it |
test_mediarestrict.py | a fresh member's photo is deleted within the restriction window; an admin's never is |
Not every string a test touches changes with the group's language, and each
file says why inline where it matters: rules.py's /newrules prompt and its
two outcome texts are hardcoded English in v1 too (Configurations.py:271, 278,283) and stay English in both language runs on purpose;
config_menu.py's denial and can't-DM-you texts, by contrast, are
hand-translated per language (_DENIED_TEXT/_CANNOT_DM_TEXT) and are
asserted in the group's own language, not pinned to English. Command triggers
sent are the real per-language alias from cb_core.textmatch.COMMAND_ALIASES
(/regras in a pt group, /rules in an en one) wherever one exists;
/adm and /isalive's hardcoded-English reply are noted as exceptions where
they occur.
Not covered, and why:
- Anything not yet ported (the progress board's "planned"/"not ported" rows) — there is nothing to drive.
es: a real v1 language, but out of scope for the task that addedpt—qa/e2e/conftest.py's_LANGUAGEStuple is the one line to extend later.
Two sandbox defects this suite found, and their fixes
Both were real, both blocked coverage, and both are now fixed in
tg_sandbox — recorded here because each has a matching regression test and
because the failure modes are worth recognising again.
join_chatdid not persist the join's own service message.POST /api/chats/{id}/joinqueued anew_chat_membersupdate but never calledSandboxStore.add_messagefor it. Most of the join chain never noticed (welcome/doomlist/mediarestrictdo not.reply()to it), butgroupguardian's captcha issuance does —message.reply(text, reply_markup=keyboard)— andtg_sandboxvalidates reply targets for real, so every self-join with captcha on came back400 Bad Request: message to reply not foundand the challenge was never issued. The captcha looked broken; nothing about the captcha was broken. Joins and leaves are now stored messages carrying aservicefield, the way Telegram models them.qa/e2e/test_captcha.pyasserts the challenge'sreply_to_message_idpoints at the join message, which is the assertion that would have caught it.- No DM could be addressed by a user's own id. Real Telegram identifies
a private chat by
chat.id == user.id, andconfig_menu.open_config_menucallsbot.send_message(ctx.actor.user_id, ...)on exactly that assumption. Every chat the control API minted took an id from the descending group-chat counter, so no handler answering privately could ever reach one.POST /api/users/{id}/dmnow creates the chat whose id is the user's id — "this user pressed Start" — and a bot sending to a user with no DM gets Telegram's real403 Forbidden: bot can't initiate conversation with a userrather than a misleading "chat not found".test_config_menu.pycovers both branches.
A real gateway behaviour running pt groups surfaced (v1 parity, not a bug)
Adding a pt group to this suite ran straight into
cb_gateway/handlers/setlang.py's on_bot_added_to_group: every time the bot
is (re-)added to a group, it derives that group's language from
language_code on whoever performed the add and overwrites group_configs .language with it — unconditionally, via group_config.set_config. This is
COOKIEBOT.py:121-135's set_language ported verbatim, not a v2 regression:
on real Telegram the adder is always a human admin, so this is a legitimate
"detect the language from whoever just invited me" heuristic, faithful to v1.
qa/e2e/conftest.py's _make_group has no admin user to attribute the bot's
join to at the point it happens, so it self-joins the bot with no
by_user_id — a shape real Telegram cannot produce, but tg_sandbox allows.
That makes the join's from_user resolve to the bot's own sandbox record,
whose language_code defaults to "en" and derives to the literal "eng" —
silently overwriting whatever language _make_group had just configured, on
every single group this suite creates, regardless of which language was
asked for. Undetected until now because it happened to coincide with the only
language ever tested ("eng" and "en" both resolve to the same canonical
en); a pt group failed loudly the instant it existed, with the button
label "ADMINS: Approve" where "ADMINS: Aprovar" was expected — as clean a
demonstration as this suite could ask for of exactly the gap it was built to
close.
Fixed inside the fixture, not the handler (out of this task's file
ownership, and there is nothing to fix in the handler — it is working as
designed): _reassert_group_language waits for the setMyCommands call that
always follows setlang's overwrite, then corrects group_configs.language
back to what the test asked for and invalidates the gateway's cache for it
the same way group_config.invalidate does (delete the L2 key, publish on
cb_core.cache.INVALIDATION_CHANNEL) — a plain SQL UPDATE alone would not
be seen by the gateway's own 30s in-process cache. See its docstring in
qa/e2e/conftest.py for the full mechanics.
Troubleshooting
- A test times out with a long
api_callstail in the message: that tail is the point — read it. It shows every Bot API call the gateway actually made since the assertion's own starting offset. - Every test in a run fails/errors the same way at startup: check
_wait_ready's exception — it includes the tail of the failing process's own log file (path printed in the error). Both processes' logs are written unbuffered (PYTHONUNBUFFERED=1) specifically so a failure while the process is still up shows something. - A UniqueViolation on
group_configs/groupsduring fixture setup: means a previous run of this suite was killed hard enough to skip its ownfinally(apytest --pdbsession left open, aSIGKILL, ...). The fixture self-heals (DELETE FROM groups WHERE group_id = ...before every insert) — if you see this, it means the self-heal itself needs to run again, which happens automatically on the next attempt. - The very first test after a cold start occasionally takes 30-90s instead
of a few seconds: this is the database warm-up (see above) absorbing a
real, occasionally-severe hiccup — this environment has been observed to
drop the gateway's very first connection to the sandbox, which aiogram's
own polling loop then retries with growing backoff until it clears. It is
not a bug in the code under test;
_warm_up_database_pool's docstring has the full explanation.