Skip to content

Commit af0e5d9

Browse files
AchoArnoldCopilot
andcommitted
docs: add integration test setup design spec
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent adbcd2e commit af0e5d9

1 file changed

Lines changed: 248 additions & 0 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
# Integration Test Setup for httpSMS API
2+
3+
## Problem
4+
5+
The httpSMS API has no integration tests that verify the full SMS send/receive flow end-to-end. We need a CI-gated integration test that runs the entire stack in Docker and validates the core message lifecycle before deploying the API.
6+
7+
## Approach
8+
9+
Run the full application stack (API + PostgreSQL + Redis) in Docker alongside an **emulator** service that acts as a fake Android phone. The emulator implements a fake FCM server endpoint so the API's Firebase messaging client sends push notifications to it (instead of Google). The emulator then responds with SENT/DELIVERED events, completing the SMS lifecycle. A Go test runner exercises the API externally and asserts on final message state.
10+
11+
## Architecture
12+
13+
```
14+
┌─────────────────────────────────────────────────────────┐
15+
│ Docker Compose (tests/docker-compose.yml) │
16+
│ │
17+
│ ┌──────────┐ ┌───────┐ ┌──────────────────────────┐ │
18+
│ │PostgreSQL│ │ Redis │ │ API (existing Dockerfile)│ │
19+
│ └──────────┘ └───────┘ └────────────┬─────────────┘ │
20+
│ │ FCM push │
21+
│ ▼ │
22+
│ ┌──────────────────────────┐ │
23+
│ │ Emulator (fake phone) │ │
24+
│ │ - Fake FCM server :9090 │ │
25+
│ │ - Fires SENT/DELIVERED │ │
26+
│ │ events back to API │ │
27+
│ └──────────────────────────┘ │
28+
└─────────────────────────────────────────────────────────┘
29+
30+
│ HTTP calls (send SMS, get message, etc.)
31+
32+
┌────────┴──────────┐
33+
│ Test Runner (Go) │ ← runs on host / in CI
34+
│ go test ./... │
35+
└───────────────────┘
36+
```
37+
38+
## Components
39+
40+
### 1. `tests/docker-compose.yml`
41+
42+
Brings up the full stack:
43+
44+
- **postgres** — Same as root `docker-compose.yml`, seeded with `tests/seed.sql`
45+
- **redis** — Standard Redis
46+
- **api** — Built from `api/Dockerfile`, configured with `FCM_ENDPOINT=http://emulator:9090` to redirect Firebase messaging to the emulator
47+
- **emulator** — Built from `tests/emulator/Dockerfile`, receives FCM pushes and fires events back
48+
49+
### 2. `tests/emulator/` (Go project)
50+
51+
A lightweight Go HTTP server that:
52+
53+
- Exposes `POST /v1/projects/{project}/messages:send` — mimics the FCM v1 API. Receives push notification payloads from the API's Firebase messaging client.
54+
- Exposes `POST /token` — returns a fake OAuth2 access token (the Firebase SDK calls this before sending FCM). Response format: `{"access_token": "fake-token", "token_type": "Bearer", "expires_in": 3600}`
55+
- Exposes `GET /health` — health check endpoint
56+
- On receiving a push with `KEY_MESSAGE_ID` in the data payload:
57+
1. Calls `GET http://api:8000/v1/messages/outstanding?message_id={messageID}` (using phone API key) to fetch the message like a real phone would
58+
2. Waits a brief delay (e.g., 200ms)
59+
3. Calls `POST http://api:8000/v1/messages/{messageID}/events` with event `SENT` (using phone API key)
60+
4. Waits another brief delay (e.g., 200ms)
61+
5. Calls `POST http://api:8000/v1/messages/{messageID}/events` with event `DELIVERED` (using phone API key)
62+
- All API calls authenticated with the seeded phone API key (`x-api-key` header)
63+
- Asserts it received the correct FCM payload structure (path, data.KEY_MESSAGE_ID present)
64+
65+
### 3. `tests/seed.sql`
66+
67+
SQL script that runs on PostgreSQL startup to create:
68+
69+
- A test user: `id='test-user-id'`, `email='test@httpsms.com'`, `api_key='test-user-api-key'`, `subscription_name='pro'`
70+
- A system user (for event queue): `id='system-user-id'`, `api_key='system-user-api-key'`
71+
- A phone: `id=<uuid>`, `user_id='test-user-id'`, `phone_number='+18005550199'`, `fcm_token='fake-fcm-token'`
72+
- A phone API key: `id=<uuid>`, `user_id='test-user-id'`, `api_key='test-phone-api-key'`, `phone_numbers=['+18005550199']`
73+
74+
### 4. API Modification — FCM Transport Override
75+
76+
In `api/pkg/di/container.go`, modify `FirebaseMessagingClient()`:
77+
78+
- When `FCM_ENDPOINT` env var is set, create the Firebase App with a custom HTTP client whose `Transport` rewrites request URLs from `https://fcm.googleapis.com` to the value of `FCM_ENDPOINT`
79+
- This requires no changes to business logic — the messaging client works normally but routes traffic to the emulator
80+
- The Firebase credentials must be a syntactically valid fake service account JSON with `token_uri` pointing to `http://emulator:9090/token`
81+
82+
### 4b. `tests/.env.test` — API environment for tests
83+
84+
```env
85+
ENV=production
86+
GCP_PROJECT_ID=httpsms-test
87+
EVENTS_QUEUE_TYPE=emulator
88+
EVENTS_QUEUE_NAME=events-local
89+
EVENTS_QUEUE_ENDPOINT=http://localhost:8000/v1/events
90+
EVENTS_QUEUE_USER_API_KEY=system-user-api-key
91+
EVENTS_QUEUE_USER_ID=system-user-id
92+
FCM_ENDPOINT=http://emulator:9090
93+
DATABASE_URL=postgresql://dbusername:dbpassword@postgres:5432/httpsms
94+
DATABASE_URL_DEDICATED=postgresql://dbusername:dbpassword@postgres:5432/httpsms
95+
REDIS_URL=redis://@redis:6379
96+
APP_PORT=8000
97+
ENTITLEMENT_ENABLED=false
98+
USE_HTTP_LOGGER=true
99+
FIREBASE_CREDENTIALS=<fake service account JSON with token_uri=http://emulator:9090/token>
100+
```
101+
102+
### 5. `tests/integration_test.go` (Go test files)
103+
104+
Go tests using the standard `testing` package + `testify` for assertions:
105+
106+
**Test 1: Send SMS E2E**
107+
108+
1. `POST /v1/messages/send` with `from=<test_phone>`, `to=+18005550100`, `content="Hello"` (using user API key `x-api-key` header)
109+
2. Extract message ID from response
110+
3. Poll `GET /v1/messages/{id}` every 200ms with max 15s timeout (using user API key)
111+
4. Assert message status reaches `delivered`
112+
5. Assert message events include both `SENT` and `DELIVERED`
113+
114+
**Test 2: Receive SMS**
115+
116+
1. `POST /v1/messages/receive` (using phone API key auth) with `from=+18005550100`, `to=+18005550199`, `content="Hi there"`, `sim="SIM1"`, `timestamp=<now>`
117+
2. Extract message ID from response
118+
3. `GET /v1/messages/{id}` (using user API key auth)
119+
4. Assert message exists with correct content, from, to fields
120+
5. Assert status is `received`
121+
122+
### 6. `.github/workflows/integration-test.yml`
123+
124+
GitHub Actions workflow:
125+
126+
```yaml
127+
name: integration-test
128+
on:
129+
push:
130+
branches: [main]
131+
pull_request:
132+
branches: [main]
133+
134+
jobs:
135+
integration-test:
136+
runs-on: ubuntu-latest
137+
steps:
138+
- Checkout
139+
- Docker Compose up (tests/docker-compose.yml)
140+
- Wait for health checks (API + emulator)
141+
- Run: cd tests && go test -v -timeout 120s ./...
142+
- Docker Compose down
143+
144+
deploy-api:
145+
needs: integration-test
146+
# existing deploy logic
147+
```
148+
149+
The `deploy-api` job depends on `integration-test` passing.
150+
151+
## FCM Redirect Implementation Detail
152+
153+
The Firebase Admin Go SDK's messaging client sends HTTP POST requests to:
154+
155+
```
156+
https://fcm.googleapis.com/v1/projects/{project_id}/messages:send
157+
```
158+
159+
We intercept this by providing a custom `http.RoundTripper`:
160+
161+
```go
162+
type fcmRedirectTransport struct {
163+
target string // e.g., "http://emulator:9090"
164+
base http.RoundTripper
165+
}
166+
167+
func (t *fcmRedirectTransport) RoundTrip(req *http.Request) (*http.Response, error) {
168+
// Rewrite: https://fcm.googleapis.com/... → http://emulator:9090/...
169+
req.URL.Scheme = "http"
170+
req.URL.Host = strings.TrimPrefix(t.target, "http://")
171+
return t.base.RoundTrip(req)
172+
}
173+
```
174+
175+
This is injected via `option.WithHTTPClient()` when creating the Firebase App in the DI container.
176+
177+
## Fake Firebase Credentials
178+
179+
For the integration test environment, we provide a minimal fake service account JSON:
180+
181+
```json
182+
{
183+
"type": "service_account",
184+
"project_id": "httpsms-test",
185+
"private_key_id": "test",
186+
"private_key": "-----BEGIN RSA PRIVATE KEY-----\n<test key>\n-----END RSA PRIVATE KEY-----\n",
187+
"client_email": "test@httpsms-test.iam.gserviceaccount.com",
188+
"client_id": "123456789",
189+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
190+
"token_uri": "http://emulator:9090/token",
191+
"auth_provider_x509_cert_url": "http://emulator:9090/certs",
192+
"client_x509_cert_url": "http://emulator:9090/certs/test"
193+
}
194+
```
195+
196+
The emulator implements:
197+
198+
- `POST /token` — Accepts JWT assertion grant, returns `{"access_token": "fake-token", "token_type": "Bearer", "expires_in": 3600}`
199+
- Does NOT validate the JWT signature — just returns a valid token response
200+
201+
## Docker Health Checks & Orchestration
202+
203+
Services start in order with health dependencies:
204+
205+
1. **postgres** — healthy when `pg_isready` passes
206+
2. **redis** — healthy when accepting connections
207+
3. **emulator** — healthy when `GET /health` returns 200
208+
4. **api** — starts after postgres+redis+emulator healthy, healthy when `GET /v1/` returns (or a dedicated health endpoint)
209+
210+
Test runner waits for all services healthy before executing `go test`.
211+
212+
## File Structure
213+
214+
```
215+
tests/
216+
├── docker-compose.yml
217+
├── seed.sql
218+
├── go.mod
219+
├── go.sum
220+
├── integration_test.go
221+
├── helpers_test.go # shared HTTP client, polling helpers
222+
├── .env.test # env vars for the API in test mode
223+
└── emulator/
224+
├── Dockerfile
225+
├── go.mod
226+
├── go.sum
227+
├── main.go # entry point, starts HTTP server
228+
├── fcm_handler.go # fake FCM endpoint
229+
├── token_handler.go # fake OAuth2 token endpoint
230+
└── events.go # fires SENT/DELIVERED events to API
231+
```
232+
233+
## Key Design Decisions
234+
235+
1. **DB seeding over Firebase Auth emulator** — Simpler, keeps focus on SMS flow testing. Auth is not what we're validating.
236+
2. **Real FCM code path with redirected transport** — Tests the actual Firebase SDK integration, payload construction, and error handling. More confidence than a noop mock.
237+
3. **Emulator as separate Go project** — Clean separation, own Dockerfile, own module. Doesn't pollute the API codebase.
238+
4. **Test runner runs on host (not in Docker)** — Simpler debugging, standard `go test` output, easier CI integration.
239+
5. **Polling with timeout for async assertions** — The send flow is async (event-driven). Polling with backoff is the pragmatic approach.
240+
241+
## Out of Scope
242+
243+
- Testing the web frontend
244+
- Testing the Android app
245+
- Load/performance testing
246+
- Testing auth flows (login, registration)
247+
- Testing billing/entitlements
248+
- MMS/attachment testing (can be added later)

0 commit comments

Comments
 (0)