Skip to content

Commit f7f4719

Browse files
Add local AWS test environment
Closes #5
1 parent a860051 commit f7f4719

13 files changed

Lines changed: 397 additions & 1 deletion

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@ ALLOWED_HOSTS=localhost,127.0.0.1
88
CSRF_TRUSTED_ORIGINS=
99
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
1010
AWS_SES_CONFIGURATION_SET=
11+
AWS_ENDPOINT_URL=
12+
AWS_ACCESS_KEY_ID=
13+
AWS_SECRET_ACCESS_KEY=
1114
SQS_TRANSACTIONAL_EMAIL_QUEUE_URL=
1215
SQS_CAMPAIGN_EMAIL_QUEUE_URL=
1316
SQS_EMAIL_EVENTS_QUEUE_URL=
1417
SQS_SES_WEBHOOKS_QUEUE_URL=
18+
TRANSACTIONAL_EMAIL_QUEUE_NAME=transactional-email
19+
CAMPAIGN_EMAIL_QUEUE_NAME=campaign-email
20+
SES_WEBHOOKS_QUEUE_NAME=ses-webhooks
21+
EMAIL_EVENTS_QUEUE_NAME=email-events

Makefile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: setup migrate run test lint format
1+
.PHONY: setup migrate run localstack test test-aws-local lint format
22

33
setup:
44
@test -f .env || cp .env.example .env
@@ -11,9 +11,15 @@ migrate:
1111
run:
1212
uv run python manage.py runserver
1313

14+
localstack:
15+
docker compose --profile aws-local up localstack
16+
1417
test:
1518
uv run pytest
1619

20+
test-aws-local:
21+
uv run pytest -m aws_local
22+
1723
lint:
1824
uv run ruff check .
1925

conftest.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import os
2+
import socket
3+
from uuid import uuid4
4+
5+
import boto3
6+
import pytest
7+
8+
LOCALSTACK_ENDPOINT = os.environ.get("AWS_ENDPOINT_URL", "http://localhost:4566")
9+
AWS_REGION = os.environ.get("AWS_REGION", "us-east-1")
10+
11+
os.environ.setdefault("AWS_ACCESS_KEY_ID", "test")
12+
os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "test")
13+
os.environ.setdefault("AWS_DEFAULT_REGION", AWS_REGION)
14+
os.environ.setdefault("AWS_REGION", AWS_REGION)
15+
os.environ.setdefault("AWS_ENDPOINT_URL", LOCALSTACK_ENDPOINT)
16+
os.environ.setdefault("AWS_EC2_METADATA_DISABLED", "true")
17+
18+
19+
@pytest.fixture
20+
def aws_test_env(monkeypatch):
21+
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "test")
22+
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "test")
23+
monkeypatch.setenv("AWS_DEFAULT_REGION", AWS_REGION)
24+
monkeypatch.setenv("AWS_REGION", AWS_REGION)
25+
monkeypatch.setenv("AWS_ENDPOINT_URL", LOCALSTACK_ENDPOINT)
26+
27+
28+
@pytest.fixture
29+
def localstack_available(aws_test_env):
30+
host, port = _host_port_from_endpoint(LOCALSTACK_ENDPOINT)
31+
try:
32+
with socket.create_connection((host, port), timeout=0.5):
33+
return True
34+
except OSError:
35+
pytest.skip(f"LocalStack is not available at {LOCALSTACK_ENDPOINT}")
36+
37+
38+
@pytest.fixture
39+
def local_sqs_client(localstack_available):
40+
return boto3.client("sqs", region_name=AWS_REGION, endpoint_url=LOCALSTACK_ENDPOINT)
41+
42+
43+
@pytest.fixture
44+
def unique_queue_name():
45+
def build(prefix):
46+
return f"{prefix}-{uuid4().hex}"
47+
48+
return build
49+
50+
51+
def _host_port_from_endpoint(endpoint):
52+
without_scheme = endpoint.removeprefix("http://").removeprefix("https://")
53+
host_port = without_scheme.split("/", 1)[0]
54+
if ":" not in host_port:
55+
return host_port, 443 if endpoint.startswith("https://") else 80
56+
host, port = host_port.rsplit(":", 1)
57+
return host, int(port)

datamailer/settings.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,14 @@ def bool_env(name, *, default):
118118
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "newsletter@example.com")
119119

120120
AWS_REGION = os.environ.get("AWS_REGION", "us-east-1")
121+
AWS_ENDPOINT_URL = os.environ.get("AWS_ENDPOINT_URL", "")
121122
PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "http://localhost:8000").rstrip("/")
122123
AWS_SES_CONFIGURATION_SET = os.environ.get("AWS_SES_CONFIGURATION_SET", "")
123124
SQS_TRANSACTIONAL_EMAIL_QUEUE_URL = os.environ.get("SQS_TRANSACTIONAL_EMAIL_QUEUE_URL", "")
124125
SQS_CAMPAIGN_EMAIL_QUEUE_URL = os.environ.get("SQS_CAMPAIGN_EMAIL_QUEUE_URL", "")
125126
SQS_EMAIL_EVENTS_QUEUE_URL = os.environ.get("SQS_EMAIL_EVENTS_QUEUE_URL", "")
126127
SQS_SES_WEBHOOKS_QUEUE_URL = os.environ.get("SQS_SES_WEBHOOKS_QUEUE_URL", "")
128+
TRANSACTIONAL_EMAIL_QUEUE_NAME = os.environ.get("TRANSACTIONAL_EMAIL_QUEUE_NAME", "transactional-email")
129+
CAMPAIGN_EMAIL_QUEUE_NAME = os.environ.get("CAMPAIGN_EMAIL_QUEUE_NAME", "campaign-email")
130+
SES_WEBHOOKS_QUEUE_NAME = os.environ.get("SES_WEBHOOKS_QUEUE_NAME", "ses-webhooks")
131+
EMAIL_EVENTS_QUEUE_NAME = os.environ.get("EMAIL_EVENTS_QUEUE_NAME", "email-events")

docker-compose.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
services:
2+
localstack:
3+
image: localstack/localstack:stable
4+
profiles: ["aws-local"]
5+
ports:
6+
- "4566:4566"
7+
environment:
8+
SERVICES: sqs,ses
9+
AWS_DEFAULT_REGION: us-east-1
10+
DEBUG: "0"
11+
healthcheck:
12+
test: ["CMD", "curl", "-fsS", "http://localhost:4566/_localstack/health"]
13+
interval: 5s
14+
timeout: 3s
15+
retries: 20

docs/testing.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Testing
2+
3+
Datamailer tests must not require real AWS credentials for normal local development or CI.
4+
5+
## Default Test Suite
6+
7+
Run:
8+
9+
```bash
10+
make test
11+
```
12+
13+
The default suite uses:
14+
15+
- Django unit/view tests.
16+
- Pure Python worker helper tests.
17+
- `botocore.stub.Stubber` for SES payload assertions.
18+
- Skipped LocalStack tests when no local AWS endpoint is running.
19+
20+
## Local AWS Tests
21+
22+
Run LocalStack:
23+
24+
```bash
25+
make localstack
26+
```
27+
28+
In another terminal:
29+
30+
```bash
31+
AWS_ACCESS_KEY_ID=test \
32+
AWS_SECRET_ACCESS_KEY=test \
33+
AWS_ENDPOINT_URL=http://localhost:4566 \
34+
make test-aws-local
35+
```
36+
37+
Local AWS tests are marked with:
38+
39+
```python
40+
pytestmark = pytest.mark.aws_local
41+
```
42+
43+
These tests use LocalStack for SQS wiring. They create unique queue names per test and do not depend on real AWS resources.
44+
45+
## What Uses Mocks
46+
47+
SES correctness should primarily use `botocore.stub.Stubber`. LocalStack SES support can be useful for smoke tests, but the important checks are exact payloads and how the app handles returned message IDs.
48+
49+
Lambda workers should expose pure Python handlers that accept AWS event dictionaries. Tests invoke those handlers directly with SQS-shaped events instead of trying to run Lambda inside LocalStack.
50+
51+
## AWS Safety Rules
52+
53+
- Test settings use fake credentials.
54+
- Tests that call AWS-compatible endpoints must pass `endpoint_url`.
55+
- Real AWS credentials are reserved for staging smoke tests.
56+
- Standard SQS is at-least-once, so worker tests must cover idempotency as sender logic is added.

mailing/aws.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import boto3
2+
from django.conf import settings
3+
4+
5+
def aws_client(service_name, *, endpoint_url=None):
6+
return boto3.client(
7+
service_name,
8+
region_name=settings.AWS_REGION,
9+
endpoint_url=endpoint_url if endpoint_url is not None else settings.AWS_ENDPOINT_URL or None,
10+
)
11+
12+
13+
def sqs_client(*, endpoint_url=None):
14+
return aws_client("sqs", endpoint_url=endpoint_url)
15+
16+
17+
def ses_client(*, endpoint_url=None):
18+
return aws_client("ses", endpoint_url=endpoint_url)

mailing/ses.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from django.conf import settings
2+
3+
4+
def send_email(*, ses_client, source, to_email, subject, html_body, text_body=""):
5+
body = {"Html": {"Charset": "UTF-8", "Data": html_body}}
6+
if text_body:
7+
body["Text"] = {"Charset": "UTF-8", "Data": text_body}
8+
9+
params = {
10+
"Source": source,
11+
"Destination": {"ToAddresses": [to_email]},
12+
"Message": {
13+
"Subject": {"Charset": "UTF-8", "Data": subject},
14+
"Body": body,
15+
},
16+
}
17+
if settings.AWS_SES_CONFIGURATION_SET:
18+
params["ConfigurationSetName"] = settings.AWS_SES_CONFIGURATION_SET
19+
20+
return ses_client.send_email(**params)["MessageId"]

mailing/sqs.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import json
2+
3+
4+
def partial_batch_response(failed_message_ids):
5+
return {
6+
"batchItemFailures": [
7+
{"itemIdentifier": message_id}
8+
for message_id in failed_message_ids
9+
]
10+
}
11+
12+
13+
def records_from_messages(messages, *, event_source_arn="arn:aws:sqs:us-east-1:000000000000:test"):
14+
return {
15+
"Records": [
16+
{
17+
"messageId": message["MessageId"],
18+
"receiptHandle": message["ReceiptHandle"],
19+
"body": message["Body"],
20+
"attributes": message.get("Attributes", {}),
21+
"messageAttributes": message.get("MessageAttributes", {}),
22+
"md5OfBody": message.get("MD5OfBody", ""),
23+
"eventSource": "aws:sqs",
24+
"eventSourceARN": event_source_arn,
25+
"awsRegion": "us-east-1",
26+
}
27+
for message in messages
28+
]
29+
}
30+
31+
32+
def json_body(record):
33+
return json.loads(record["body"])
34+
35+
36+
def process_sqs_event(event, handler):
37+
failed_message_ids = []
38+
for record in event.get("Records", []):
39+
try:
40+
handler(json_body(record), record)
41+
except Exception:
42+
failed_message_ids.append(record["messageId"])
43+
44+
return partial_batch_response(failed_message_ids)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import json
2+
3+
import pytest
4+
5+
from mailing.sqs import json_body, process_sqs_event, records_from_messages
6+
7+
pytestmark = pytest.mark.aws_local
8+
9+
10+
def test_localstack_sqs_can_enqueue_and_receive(local_sqs_client, unique_queue_name):
11+
queue_url = local_sqs_client.create_queue(
12+
QueueName=unique_queue_name("transactional-email"),
13+
)["QueueUrl"]
14+
15+
local_sqs_client.send_message(
16+
QueueUrl=queue_url,
17+
MessageBody=json.dumps({"type": "transactional_email", "message_id": "msg_123"}),
18+
)
19+
20+
response = local_sqs_client.receive_message(
21+
QueueUrl=queue_url,
22+
MaxNumberOfMessages=1,
23+
WaitTimeSeconds=1,
24+
)
25+
26+
event = records_from_messages(response["Messages"])
27+
processed = []
28+
batch_response = process_sqs_event(event, lambda body, record: processed.append(body))
29+
30+
assert json_body(event["Records"][0]) == {"type": "transactional_email", "message_id": "msg_123"}
31+
assert processed == [{"type": "transactional_email", "message_id": "msg_123"}]
32+
assert batch_response == {"batchItemFailures": []}
33+
34+
35+
def test_localstack_sqs_can_attach_dead_letter_queue(local_sqs_client, unique_queue_name):
36+
dlq_url = local_sqs_client.create_queue(QueueName=unique_queue_name("transactional-email-dlq"))["QueueUrl"]
37+
dlq_arn = local_sqs_client.get_queue_attributes(
38+
QueueUrl=dlq_url,
39+
AttributeNames=["QueueArn"],
40+
)["Attributes"]["QueueArn"]
41+
42+
queue_url = local_sqs_client.create_queue(
43+
QueueName=unique_queue_name("transactional-email"),
44+
Attributes={
45+
"RedrivePolicy": json.dumps({
46+
"deadLetterTargetArn": dlq_arn,
47+
"maxReceiveCount": "3",
48+
})
49+
},
50+
)["QueueUrl"]
51+
52+
attributes = local_sqs_client.get_queue_attributes(
53+
QueueUrl=queue_url,
54+
AttributeNames=["RedrivePolicy"],
55+
)["Attributes"]
56+
57+
assert json.loads(attributes["RedrivePolicy"])["deadLetterTargetArn"] == dlq_arn

0 commit comments

Comments
 (0)