|
| 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