Testing¶
make test # ~550 tests, about 30 seconds, no network
make rust # Rust: fmt, clippy, tests (parsers on hostile HTML included)
make integration # the built image as two servers with Valkey (Docker)
The default suite never talks to t.me. Every merge to main is deployed to
production, so these tests and the CI gates are what stands
between a broken commit and users.
Contract snapshots¶
tests/test_snapshots.py calls every endpoint against recorded t.me
pages and compares the complete JSON response with a file in
tests/snapshots/. Any change to what the API returns — a renamed field, a
different number, a missing post — fails a test and shows up as a readable
diff. The JSON files are compared parsed, so each response's exact bytes are
pinned as well, by their SHA-256 in tests/snapshots/raw_digests.json: key
order, number formatting and string escaping must not change either, which is
what lets code move from pydantic to Rust without touching the contract.
The recorded pages live in tests/fixtures/telegram/ (gzip) with a
manifest.json describing which request each file answers. A
FixtureFetcher replaces the network layer and fails the test on any request
that was not recorded. Time is frozen with time-machine, because the feed
score depends on "now".
Coverage includes six channels (durov, telegram, stranaua, tginfo,
nebudetafk, nebudetgg), both parser modes, pagination in both directions,
previews, a missing channel, the feed, and individual posts with an inline
button, a poll, a sticker, a reply, a link preview and a voice message. A
synthetic feed, seeded straight into the cache, adds collapsed duplicates and
pages the parser would never produce (no username, no channel info, posts
without footer or content).
tests/test_graphql_contract.py does the same for GraphQL: the full schema
(SDL) in tests/snapshots/graphql/schema.graphql, a snapshot per query, and
parity checks that every GraphQL result equals the matching REST response.
tests/test_openapi_contract.py pins the REST contract as a whole, the
OpenAPI document with every feature on, in tests/snapshots/openapi.json.
Changing the output on purpose¶
make snapshots # UPDATE_SNAPSHOTS=1 pytest tests/test_snapshots.py tests/test_graphql_contract.py tests/test_openapi_contract.py
git diff --stat tests/snapshots
git diff tests/snapshots # review every change
Breaking changes¶
A snapshot only shows that the contract changed. scripts/api_contract.py
decides whether the change breaks clients, by comparing openapi.json and
schema.graphql with the base of the pull request, which is what production
serves:
Breaking: an operation, field or enum value removed, a response field that
may now be missing or have another type (null included), and anything that
rejects requests accepted before (a new required parameter or field, a
narrower type, a lower limit). Additions are compatible. The GraphQL schema
is compared by graphql-core (find_breaking_changes). The Guards workflow
fails a pull request with a breaking change unless it is labelled
breaking-change, which means a major version (see
Releasing).
Recording new pages¶
make fixtures # re-record everything (changes many snapshots)
make fixtures CHANNELS="nebudetgg" # add or refresh specific channels
Recording uses the production network stack. Re-recording replaces content
(views, new posts), so regenerate and review snapshots afterwards. To cover a
rare post type, add its ID to EXTRA_POSTS in scripts/record_fixtures.py.
Unit tests¶
tests/unit/ covers the pieces in isolation:
| File | Covers |
|---|---|
test_client.py |
URL building, status mapping, validation, the single network retry |
test_fetcher.py |
ReqwestFetcher and BrowserFetcher against a real local HTTP server: gzip, profile headers, the XHR, empty POST bodies, timeouts, network errors |
test_cache.py |
In-process cache (limit, compression, expiry), Redis backend, circuit breaker, two-level reads, fills, outage |
test_memory_trim.py |
Periodic malloc_trim (glibc only), disabling it |
test_coalesce.py |
Single-flight sharing, error propagation, cancellation safety |
test_popularity.py |
Popularity decay and leader election on Redis (fakeredis with Lua) |
test_services.py |
Page and response caches, previews, stale-while-revalidate feed, refresher scheduling |
test_parsing.py |
The native page parser seen from Python: entities, media, forwards, broken-post isolation |
test_feed_ranking.py |
Feed signals and the native ranker: relative engagement, freshness, duplicates, topics, diversity |
test_graphql.py |
GraphQL schema shape, resolvers, errors |
test_graphql_limits.py |
GraphQL depth, cost, alias, token and batch limits |
test_api.py |
Validation, feature flags, headers, rate limiting, CORS, docs |
test_runtime.py |
Embedded refresher lifecycle, JSON logging |
test_docs_config.py |
Documentation translations: every page present, configuration in sync with English |
test_api_contract.py |
Which contract changes scripts/api_contract.py reports as breaking |
test_layers.py |
Every module of the package has a place in the import-linter layers |
test_properties.py |
Property-based tests, see below |
Tests are async-aware (pytest-asyncio in auto mode) and warnings are
errors.
Property-based and fuzz tests¶
Every t.me page is input the service does not control, and the native
extension is built with panic = "abort": a panic in a parser takes the
whole worker process down. Generated input looks for what hand-written cases
miss.
tests/unit/test_properties.py(hypothesis): whatever page t.me returns (recorded pages with parts deleted, repeated, cut off or spliced in), every endpoint answers 200, 404 or 502, never 500; the parsing wrappers fail only withTelegramError; the protection middleware answers anyX-PoWheader, path and method with a well-formed response, accepts a valid proof exactly once and counts the rate limit exactly; the feed holds each post once, only from the requested channels, whatever their order. Examples come from a fixed seed;HYPOTHESIS_PROFILE=nightlysearches longer with random ones.rust/telegram-me-native/tests/hostile_html.rs(cargo test): every recorded page whole and with seeded mutations, soup of t.me markup and absurd values, deeply nested markup on a thread with a 1 MiB stack, and parse times that must grow linearly (4× the input in under 9× the time) for many posts, entities, unclosed tags and look-ahead patterns.HOSTILE_CASESandHOSTILE_SEEDmake it run longer or differently.-
rust/telegram-me-native/fuzz/(cargo-fuzz, nightly Rust): a coverage-guided fuzzer over all page parsers, seeded with the recorded pages:
Memory¶
tests/test_memory.py (marker memory) sends thousands of requests straight
to the ASGI app over aliases of the recorded channels (tests/workload.py)
and fails when Python allocations or, on Linux, resident memory keep growing
after a warm-up, or when the in-process cache exceeds its limit. It is part of
the default run, so CI runs it; skip it locally with -m "not memory". The
cache's entries live on the Rust heap, which tracemalloc does not see: that
the limit counts what they really hold is checked by
rust/telegram-me-native/tests/cache_memory.rs with a counting allocator.
scripts/memory_profile.py prints the numbers behind it: import cost of the
main process and a worker, the size of one cached page, and RSS and Python
allocations sampled during a long workload:
RSS is read from /proc, so run it on Linux for meaningful numbers.
Integration tests¶
tests/integration/ runs the built image as it runs in production, which the
tests above can only imitate with fakeredis or inside one process:
tests/integration/compose.yaml starts two servers with two granian workers
each, sharing a real Valkey, in front of a fake t.me
(tests/integration/fake_telegram.py serves the recorded pages and counts
requests; the servers reach it through TELEGRAM_BASE_URL).
| Test | Shows |
|---|---|
test_second_server_reads_the_first_servers_cache |
the two-level cache: a page fetched by one server is served by the other from Redis |
test_concurrent_misses_on_both_servers_fetch_once |
the fetch lease: 24 concurrent misses on 4 processes, one request to t.me |
test_shared_rate_limit_counts_a_client_on_every_server |
RATE_LIMIT_SHARED: a client is limited although no process alone sees enough requests |
test_proof_of_work_from_one_server_is_accepted_by_the_other |
a challenge from one server solves a request to the other; replayed, forged |
test_rolling_upgrade.py |
the previous release (TME_PREVIOUS_IMAGE) and the new one read each other's cache entries |
test_valkey_outage.py |
Valkey stops: both servers keep answering; it returns: they share again |
make docker
TME_TEST_IMAGE=telegram-me:dev TME_PREVIOUS_IMAGE=docker.io/koval01/telegram-me:latest make integration
They only talk HTTP and Docker, so they run without the project installed
(uv run --only-group integration) and skip without TME_TEST_IMAGE.
Deploy scripts¶
tests/deploy/test_remote_deploy.py runs deploy/remote-deploy.sh on the
local Docker as on a production server (deploy/compose.prod.yaml, Valkey
and the fake t.me reached through the stack's gateway): the upgrade from the
previous release, a release that never becomes healthy, a release that is
healthy but fails every page request (only the smoke test notices), the
canary check (--verify), --rollback, and the proxy profile with Caddy.
tests/deploy/test_rollout.py runs deploy/rollout.sh, the fleet logic of
the deploy workflow, with stub ssh and scp: canary first, every server
that took a release rolled back when a later one, the canary or the public
smoke test fails. It needs bash 4.4 or later and is part of make test.
Coverage floors¶
CI fails when line coverage drops below a floor: fail_under in
pyproject.toml for Python, [package.metadata.coverage] fail-under-lines
in rust/telegram-me-native/Cargo.toml for Rust (cargo llvm-cov). The
Guards workflow fails a pull request that lowers a floor
(scripts/coverage_floor.py): floors only go up, together with the tests that
earn them.
Live smoke test¶
scripts/smoke_matrix.py exercises a running instance against live t.me.
Besides status codes it checks the content of the main responses (channel
identity, complete posts, pagination on the right side, feed scores): a
change of the t.me markup usually still answers 200, just with empty fields.
make up && make smoke
uv run --no-project python scripts/smoke_matrix.py --base-url https://api.example.com --preset auto
It uses only the standard library and only pages that are recorded too, so it also passes against the fake t.me. CI runs it against the freshly built image on every pull request, every deployment runs it on every server and through the public URL (see Production), and the Nightly workflow runs it against live t.me and production.
Behind Cloudflare schema validation, the invalid requests the matrix sends on
purpose are blocked at the edge (403, error 1020) before they reach the API.
Add --edge-blocks-invalid there: those requests then pass on that block as
well as on the API's 422, and any other 403 still fails. The deployment's
and Nightly's runs through the public URL use it.
Static checks¶
Besides the tests, every commit goes through checks that read the code
without running it. make lint and make audit run them locally (make
check includes both); make tools installs the binaries that uv sync does
not bring.
| Tool | Finds | Settings |
|---|---|---|
| clippy, pedantic and selected restriction and nursery lints | in the native crate: unwrap(), panic!, todo!, dbg!, printing, #[allow] without a reason; unchecked indexing and string slicing in the parser |
[lints] in rust/telegram-me-native/Cargo.toml, clippy.toml |
| cargo-deny | RustSec advisories, unmaintained and yanked crates, licenses outside the allow list, two versions of a crate, unknown registries and git sources | rust/telegram-me-native/deny.toml |
| cargo-machete | Rust dependencies no code uses | |
mypy --strict, plus warn_unreachable and twelve more error codes |
unreachable code, overrides without @override, possibly undefined names, always-true conditions, non-exhaustive match |
[tool.mypy] |
| ruff | bugbear, bandit, ... and now also unused arguments, blind except, naive datetimes, commented-out code, private members of other classes, relative imports and telegram_me_native._native |
[tool.ruff] |
| import-linter | an import against the layering: api → container → services → cache → telegram → schemas → settings, and FastAPI or strawberry below api |
[tool.importlinter] |
| vulture | functions, attributes and variables in src/ that nothing in src/ uses (only tests is not enough) |
[tool.vulture], tests/vulture_whitelist.py |
uv audit |
known vulnerabilities of the packages in uv.lock |
|
| gitleaks | secrets in staged files (pre-commit) and in every commit (CI), among them the Cloudflare bypass key | .gitleaks.toml |
| zizmor | GitHub Actions risks: template injection, persisted credentials, inherited secrets, Dependabot without cooldown | .github/zizmor.yml |
| hadolint | Dockerfile mistakes, down to style | .hadolint.yaml |
| typos | misspelled words in code and docs (English and Russian pages; Spanish ones are skipped) | .typos.toml |
| shellcheck, actionlint | the deploy scripts and the workflows | |
| trivy | high and critical vulnerabilities with a fix in the built image | make image-scan |
| dependency review | vulnerable or GPL-licensed dependencies a pull request adds | .github/workflows/dependency-review.yml |
| OpenSSF Scorecard | supply-chain practices of the repository, as code-scanning alerts | .github/workflows/scorecard.yml |
A finding is fixed, or allowed as narrowly as the tool permits with the
reason next to it: #[expect(lint, reason = "...")] in Rust (an #[allow]
fails), # noqa: CODE - why in Python, an entry with a comment in the tool's
settings. Nothing is switched off for a whole file to make a check pass.
CI jobs¶
| Workflow, job | When | Takes | Catches |
|---|---|---|---|
| CI: Rust | every push | ~3 min | fmt, clippy, cargo-deny and cargo-machete (+15 s), Rust tests (hostile HTML), Rust coverage floor |
| CI: Lint & types | every push | ~4 min | ruff, mypy, import-linter, vulture, uv audit, typos, shellcheck, actionlint, zizmor, hadolint, gitleaks over the history (together +30 s) |
| CI: Tests | every push | ~2 min | the suite above on Python 3.13 and 3.14, Python coverage floor |
| CI: Docker image smoke test | after the three above | ~2 min | the image starts hardened, smoke matrix against live t.me, graceful stop |
| CI: Image vulnerabilities | after the image | ~1 min | trivy: high and critical vulnerabilities with a fix |
| CI: Integration | after the image | ~2 min | integration tests |
| CI: Deploy scripts | after the image | ~2 min | deploy script tests |
| Guards | every pull request, label changes | ~10 s | breaking API changes, lowered coverage floors |
| Dependency review | every pull request | ~15 s | added dependencies with known vulnerabilities or a GPL license |
| Scorecard | pushes to main, Mondays |
~1 min | OpenSSF Scorecard (it reads only the default branch) |
| Nightly | 03:23 UTC daily | ~15 min | live t.me and production smoke tests, 10 min of fuzzing, long property runs; opens an issue labelled nightly |
Release (pushes to main) runs CI and Guards before the image is built and
deployed.