← Email

Test cases

email · workzone
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.
Stack and 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
test_compose.py
compose — template + data + language → finished email
Cases
invitesubject — inviter's name + product; body has the role, link, “48 hours”
resetsubject, body, link, “1 hour” text
testconfirmation subject and body — no button, no link
multipart/alternativeHTML part AND text part — both present
link twiceaction button + the same link as plain text below
text part — plainno HTML tags, link is readable
From headerfrom_address → display-name + address (RFC 5322)
missing var → rejectno data for a key → render error, not an empty substitution
HTML escapinginviter name with <, & is escaped in the HTML part (injection)
typed keysunknown key → error, not a placeholder string in code
test_i18n.py
Email language selection RU / EN
Cases
reset → recipientlanguage from the recipient's profile
test → recipientlanguage from the admin's profile
invite → org defaultorganization's language (the invitee isn't in the system yet)
untranslated languageunsupported → fall back to the default
key parityRU and EN — one set of keys, not a single gap
test_availability.py
Transport readiness — is_available() + secret
Cases
available → trueis_enabled + host + port + from_address all set
disabled → falseis_enabled=false even with the fields filled in
required missing → falseNULL in host / port / from_address — each one
last_test_ok=false — advisorydoes NOT affect is_available (not a gate)
last_test_ok=NULL“not tested” ≠ “broken” — does not block
password never leavesserialization → only a “set / not set” flag, no password_enc and no plaintext password
Integration · P0 Delivery — two modes
test_send_inline.py
Inline send — test email, verdict right away
IntegrationP0 → Inline or queue
Cases
successsend called, verdict OK, last_test_ok=true + last_test_at
SMTP failuretyped error bubbles up, last_test_ok=false, last_test_at recorded
timeoutfail verdict within the timeout, the request doesn't hang
email assembled correctlyrecipient = current admin, test subject, no link
password decrypted in sendspy: crypto core decrypt is called at send time; the plaintext password goes to SMTP auth, not to the DB / log
test_queue_task.py
SAQ queue — task handler directly
IntegrationP0 → Inline or queue
Cases
handler: compose + sendemail assembled (address, subject, link, language), send called
enqueue → instant responsetask is queued, the consumer responds immediately, without waiting for delivery
idempotencyre-enqueue with the same job_id → one task, the email isn't duplicated
consumer doesn't batchN emails = N tasks; splitting into batches is the worker's concern
successful deliverytask completed, retries never ran
Integration · P1 Delivery errors, anti-enumeration, rate
test_delivery_errors.py
Error taxonomy — 4xx vs 5xx, retries, “not delivered”
IntegrationP1 → Delivery errors
Cases
4xx / network → retrytransient failure → retry with exponential backoff
5xx → no retriesinvalid address, mailbox rejected → straight to “not delivered”
retries exhaustedtask in “not delivered”, status visible to the consumer
invite statusa failed invite delivery is reflected on the admission model
backoff growsthe delay between attempts increases, ≤ the ceiling
test_reset_anti_enum.py
Password reset — a delivery failure isn't disclosed
IntegrationP1 → Delivery errors
Cases
failure → swalloweda failed reset delivery is NOT propagated upward
uniform responsethe outcome is indistinguishable from success — anti-enumeration (mechanics in Auth)
failure to log and auditthe failure is recorded internally, nothing surfaces
test_throttling.py
Send rate — the worker's responsibility
Cases
rate limitingthe worker holds the configured send rate (config)
batch sizeno more than one batch is taken at a time (config)
parameters — configrate, batch, concurrency from settings, not hardcoded
burst of tasksmany 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