Decisions made
SMTP is mocked
No mail is actually sent. We check that the email is assembled
correctly (address, subject, link, language,
multipart/alternative) and that send was
called. The same approach is already used in the auth tests.
SAQ task — call the handler directly
Without spinning up a worker: we test the task's logic (compose +
send, retries, marking as “not delivered”), not the queue
infrastructure.
Already have done
pytest, pytest-asyncio, pytest-cov, httpx
To add
testcontainers (PostgreSQL), an SMTP mock (stubbing
aiosmtplib), time-machine (link TTLs and timeouts)
Test DB
singleton smtp_settings — a real row in the test DB,
per-test rollback
Markers
@pytest.mark.unit, @pytest.mark.integration —
filter by type
Unit
No DB or SMTP — email assembly, languages, readiness
compose — template + data + language → finished email
Cases
invite subject — inviter's name + product; body has the role, link, “48 hours”
reset subject, body, link, “1 hour” text
test confirmation subject and body — no button, no link
multipart/alternative HTML part AND text part — both present
link twice action button + the same link as plain text below
text part — plain no HTML tags, link is readable
From header from_address → display-name + address (RFC 5322)
missing var → reject no data for a key → render error, not an empty substitution
HTML escaping inviter name with <, & is escaped in the HTML part (injection)
typed keys unknown key → error, not a placeholder string in code
Email language selection RU / EN
Cases
reset → recipient language from the recipient's profile
test → recipient language from the admin's profile
invite → org default organization's language (the invitee isn't in the system yet)
untranslated language unsupported → fall back to the default
key parity RU and EN — one set of keys, not a single gap
Transport readiness — is_available() + secret
Cases
available → true is_enabled + host + port + from_address all set
disabled → false is_enabled=false even with the fields filled in
required missing → false NULL in host / port / from_address — each one
last_test_ok=false — advisory does NOT affect is_available (not a gate)
last_test_ok=NULL “not tested” ≠ “broken” — does not block
password never leaves serialization → only a “set / not set” flag, no password_enc and no plaintext password
Integration · P0
Delivery — two modes
Inline send — test email, verdict right away
Cases
success send called, verdict OK, last_test_ok=true + last_test_at
SMTP failure typed error bubbles up, last_test_ok=false, last_test_at recorded
timeout fail verdict within the timeout, the request doesn't hang
email assembled correctly recipient = current admin, test subject, no link
password decrypted in send spy: crypto core decrypt is called at send time; the plaintext password goes to SMTP auth, not to the DB / log
SAQ queue — task handler directly
Cases
handler: compose + send email assembled (address, subject, link, language), send called
enqueue → instant response task is queued, the consumer responds immediately, without waiting for delivery
idempotency re-enqueue with the same job_id → one task, the email isn't duplicated
consumer doesn't batch N emails = N tasks; splitting into batches is the worker's concern
successful delivery task completed, retries never ran
Integration · P1
Delivery errors, anti-enumeration, rate
Error taxonomy — 4xx vs 5xx, retries, “not delivered”
Cases
4xx / network → retry transient failure → retry with exponential backoff
5xx → no retries invalid address, mailbox rejected → straight to “not delivered”
retries exhausted task in “not delivered”, status visible to the consumer
invite status a failed invite delivery is reflected on the admission model
backoff grows the delay between attempts increases, ≤ the ceiling
Password reset — a delivery failure isn't disclosed
Cases
failure → swallowed a failed reset delivery is NOT propagated upward
uniform response the outcome is indistinguishable from success — anti-enumeration (mechanics in Auth)
failure to log and audit the failure is recorded internally, nothing surfaces
Send rate — the worker's responsibility
Cases
rate limiting the worker holds the configured send rate (config)
batch size no more than one batch is taken at a time (config)
parameters — config rate, batch, concurrency from settings, not hardcoded
burst of tasks many tasks at once → the worker works through them at pace, not in one burst
v2
Deferred coverage
Security alerts
fan-out to recipients (Owner / Admin) via the same queue path, dedup
of repeats, shared template layout
Structure
Test file structure
Priority (P0–P1) is orthogonal to the directories and is set by markers
(pytest -m p0), not by separate folders.
tests/email/module directory
conftest.pySMTP mock, singleton smtp_settings, factories
unit/no DB or SMTP
compose · i18n · availabilityemail assembly, languages, predicate
integration/with DB and mock SMTP
send-inline · queue-tasktwo delivery modes
delivery-errors · reset-anti-enum · throttlingfailures, security, rate