|
| 1 | +# Integration Tests: WireMock + httpsms-go Client Refactor |
| 2 | + |
| 3 | +## Problem |
| 4 | + |
| 5 | +The current integration tests use raw `net/http` calls and a custom emulator (120+ lines of Go) to simulate phone behavior. This makes tests harder to maintain and doesn't cover encryption, rate limiting, or webhook verification. We need to: |
| 6 | + |
| 7 | +1. Refactor tests to use the official `httpsms-go` client SDK |
| 8 | +2. Replace the custom emulator with WireMock (stub server + request journal) |
| 9 | +3. Add E2E encryption tests (outgoing + incoming) |
| 10 | +4. Add rate-limit verification test |
| 11 | +5. Assert webhook delivery with JWT authentication in all tests |
| 12 | + |
| 13 | +## Architecture |
| 14 | + |
| 15 | +``` |
| 16 | +┌────────────────────────────────────────────────────────┐ |
| 17 | +│ Docker Compose (tests/docker-compose.yml) │ |
| 18 | +│ │ |
| 19 | +│ ┌──────────┐ ┌───────┐ ┌─────────────────────────┐ │ |
| 20 | +│ │PostgreSQL│ │ Redis │ │ API │ │ |
| 21 | +│ └──────────┘ └───────┘ └────────────┬────────────┘ │ |
| 22 | +│ │FCM push │ |
| 23 | +│ │Webhook calls │ |
| 24 | +│ ▼ │ |
| 25 | +│ ┌─────────────────────────┐ │ |
| 26 | +│ │ WireMock 3.x (:8080) │ │ |
| 27 | +│ │ - Fake FCM endpoint │ │ |
| 28 | +│ │ - Fake OAuth token │ │ |
| 29 | +│ │ - Webhook receiver │ │ |
| 30 | +│ │ - Request journal │ │ |
| 31 | +│ └─────────────────────────┘ │ |
| 32 | +└────────────────────────────────────────────────────────┘ |
| 33 | + ▲ |
| 34 | + │ httpsms-go client + go-wiremock client |
| 35 | +┌────────┴──────────┐ |
| 36 | +│ Test Runner (Go) │ |
| 37 | +│ go test ./... │ |
| 38 | +└───────────────────┘ |
| 39 | +``` |
| 40 | + |
| 41 | +### Key Design Decisions |
| 42 | + |
| 43 | +- **WireMock replaces the custom emulator entirely**. It serves as both the fake FCM endpoint (receives push notifications from the API) and the webhook receiver (captures webhook events). |
| 44 | +- **Tests fire SENT/DELIVERED events directly** to the API via HTTP. No WireMock callbacks needed — the test controls the flow deterministically. |
| 45 | +- **Each test creates its own phone** with a random phone number for parallel test isolation. |
| 46 | +- **go-wiremock** (`github.com/wiremock/go-wiremock`) is used to configure stubs and query the request journal from test code. |
| 47 | + |
| 48 | +## Test Flow (per test) |
| 49 | + |
| 50 | +``` |
| 51 | +1. SETUP |
| 52 | + ├─ Create phone (random number, test-specific messages_per_minute) |
| 53 | + ├─ Create phone API key for that phone |
| 54 | + ├─ Create webhook pointing to WireMock with a signing key |
| 55 | + └─ Configure WireMock stubs (if not pre-loaded) |
| 56 | +
|
| 57 | +2. ACT |
| 58 | + ├─ Send/receive message via httpsms-go client |
| 59 | + └─ (For send tests) Query WireMock journal → extract KEY_MESSAGE_ID from FCM push |
| 60 | +
|
| 61 | +3. SIMULATE PHONE |
| 62 | + ├─ Fire SENT event to API (POST /v1/messages/{id}/events) |
| 63 | + └─ Fire DELIVERED event to API |
| 64 | +
|
| 65 | +4. ASSERT |
| 66 | + ├─ Verify message reached expected status via httpsms-go client |
| 67 | + ├─ Query WireMock journal for webhook events |
| 68 | + ├─ Validate JWT token: signature (HMAC-SHA256), issuer, subject, audience, expiry |
| 69 | + └─ Validate webhook payload contains correct event type and message data |
| 70 | +``` |
| 71 | + |
| 72 | +## Components |
| 73 | + |
| 74 | +### 1. Docker Compose Changes |
| 75 | + |
| 76 | +**Remove:** |
| 77 | + |
| 78 | +- `tests/emulator/` directory entirely (Dockerfile, Go source, go.mod) |
| 79 | + |
| 80 | +**Replace with WireMock:** |
| 81 | + |
| 82 | +```yaml |
| 83 | +wiremock: |
| 84 | + image: wiremock/wiremock:3x |
| 85 | + ports: |
| 86 | + - "8080:8080" |
| 87 | + volumes: |
| 88 | + - ./wiremock/mappings:/home/wiremock/mappings:ro |
| 89 | + healthcheck: |
| 90 | + test: ["CMD", "curl", "-f", "http://localhost:8080/__admin/health"] |
| 91 | + interval: 5s |
| 92 | + timeout: 5s |
| 93 | + retries: 10 |
| 94 | +``` |
| 95 | +
|
| 96 | +**Pre-loaded WireMock mappings** (`tests/wiremock/mappings/`): |
| 97 | + |
| 98 | +- `fcm-send.json` — Stub for `POST /v1/projects/*/messages:send` → returns `{"name": "projects/httpsms-test/messages/fake-id"}` |
| 99 | +- `oauth-token.json` — Stub for `POST /token` → returns `{"access_token": "fake-access-token", "token_type": "Bearer", "expires_in": 3600}` |
| 100 | +- `webhook-receiver.json` — Stub for `POST /webhooks/test` → returns 200 (catches all webhook calls) |
| 101 | + |
| 102 | +### 2. API Configuration Updates |
| 103 | + |
| 104 | +**`.env.test` changes:** |
| 105 | + |
| 106 | +- `FCM_ENDPOINT=http://wiremock:8080` (was `http://emulator:9090`) |
| 107 | + |
| 108 | +**Firebase credentials** `token_uri` points to `http://wiremock:8080/token` |
| 109 | + |
| 110 | +### 3. Seed SQL (simplified) |
| 111 | + |
| 112 | +Only seeds: |
| 113 | + |
| 114 | +- Test user (`test-user-id`, `api_key='test-user-api-key'`) |
| 115 | +- System user (`system-user-id`, for event queue auth) |
| 116 | + |
| 117 | +Phones, phone API keys, and webhooks are created per-test via the API. |
| 118 | + |
| 119 | +### 4. httpsms-go Client Additions |
| 120 | + |
| 121 | +New services to add to `github.com/NdoleStudio/httpsms-go`: |
| 122 | + |
| 123 | +#### `PhoneService` |
| 124 | + |
| 125 | +```go |
| 126 | +type PhoneUpsertParams struct { |
| 127 | + PhoneNumber string `json:"phone_number"` |
| 128 | + FcmToken string `json:"fcm_token"` |
| 129 | + MessagesPerMinute uint `json:"messages_per_minute"` |
| 130 | + MaxSendAttempts uint `json:"max_send_attempts"` |
| 131 | + MessageExpirationSeconds uint `json:"message_expiration_seconds"` |
| 132 | + SIM string `json:"sim"` |
| 133 | +} |
| 134 | + |
| 135 | +func (service *PhoneService) Upsert(ctx, params) → (*PhoneResponse, *Response, error) |
| 136 | +// PUT /v1/phones — authenticated with user API key |
| 137 | +``` |
| 138 | + |
| 139 | +#### `PhoneService` (FCM Token binding) |
| 140 | + |
| 141 | +```go |
| 142 | +type PhoneFCMTokenParams struct { |
| 143 | + PhoneNumber string `json:"phone_number"` |
| 144 | + FcmToken string `json:"fcm_token"` |
| 145 | + SIM string `json:"sim"` |
| 146 | +} |
| 147 | + |
| 148 | +func (service *PhoneService) UpsertFCMToken(ctx, params) → (*PhoneResponse, *Response, error) |
| 149 | +// PUT /v1/phones/fcm-token — authenticated with phone API key |
| 150 | +// This binds the phone to the phone API key via the auth context |
| 151 | +``` |
| 152 | + |
| 153 | +#### `PhoneAPIKeyService` |
| 154 | + |
| 155 | +```go |
| 156 | +type PhoneAPIKeyStoreParams struct { |
| 157 | + Name string `json:"name"` |
| 158 | +} |
| 159 | + |
| 160 | +func (service *PhoneAPIKeyService) Store(ctx, params) → (*PhoneAPIKeyResponse, *Response, error) |
| 161 | +// POST /v1/phone-api-keys/ — authenticated with user API key |
| 162 | +// Returns the created phone API key including its api_key value |
| 163 | +``` |
| 164 | + |
| 165 | +#### `WebhookService` |
| 166 | + |
| 167 | +```go |
| 168 | +type WebhookStoreParams struct { |
| 169 | + SigningKey string `json:"signing_key"` |
| 170 | + URL string `json:"url"` |
| 171 | + PhoneNumbers []string `json:"phone_numbers"` |
| 172 | + Events []string `json:"events"` |
| 173 | +} |
| 174 | + |
| 175 | +func (service *WebhookService) Store(ctx, params) → (*WebhookResponse, *Response, error) |
| 176 | +// POST /v1/webhooks — authenticated with user API key |
| 177 | +``` |
| 178 | + |
| 179 | +#### Phone Setup Flow (per test) |
| 180 | + |
| 181 | +The real Android phone registers via this flow, and tests must replicate it: |
| 182 | + |
| 183 | +1. `PUT /v1/phones` (user API key) — creates phone with phone_number + fcm_token + messages_per_minute |
| 184 | +2. `POST /v1/phone-api-keys/` (user API key) — creates a phone API key, returns the `api_key` value |
| 185 | +3. `PUT /v1/phones/fcm-token` (phone API key) — re-registers FCM token, which binds the phone to the API key via `PhoneAPIKeyListener.onPhoneUpdated` |
| 186 | + |
| 187 | +After step 3, the phone API key is authorized to act on behalf of that phone (fire events, receive messages, etc.). |
| 188 | + |
| 189 | +### 5. Test Cases |
| 190 | + |
| 191 | +#### `TestSendSMS_Encrypted` |
| 192 | + |
| 193 | +1. Generate random encryption key |
| 194 | +2. Create phone + phone API key + webhook |
| 195 | +3. Encrypt plaintext using `client.Cipher.Encrypt(key, "secret message")` |
| 196 | +4. Send message with `Encrypted: true` and encrypted content |
| 197 | +5. Query WireMock journal → verify FCM push arrived with `KEY_MESSAGE_ID` (FCM only carries the message ID, not content) |
| 198 | +6. Call `GET /v1/messages/outstanding?message_id={id}` (phone API key) — verify response has `encrypted: true` and content is ciphertext (not plaintext) |
| 199 | +7. Fire SENT + DELIVERED events |
| 200 | +8. Fetch message via user API key → verify `encrypted: true`, content is ciphertext |
| 201 | +9. Decrypt with `client.Cipher.Decrypt(key, content)` → assert equals original plaintext |
| 202 | +10. Verify webhook event in WireMock with valid JWT |
| 203 | +
|
| 204 | +#### `TestReceiveSMS_Encrypted` |
| 205 | +
|
| 206 | +1. Generate random encryption key |
| 207 | +2. Create phone + phone API key + webhook |
| 208 | +3. Encrypt plaintext using `client.Cipher.Encrypt(key, "incoming secret")` |
| 209 | +4. Simulate receiving an encrypted SMS (POST /v1/messages/receive with phone API key) |
| 210 | +5. Fetch message via user API key → verify `encrypted: true` |
| 211 | +6. Decrypt content → assert equals original plaintext |
| 212 | +7. Verify webhook event (`message.phone.received`) in WireMock with valid JWT |
| 213 | +
|
| 214 | +#### `TestSendSMS_RateLimit` |
| 215 | +
|
| 216 | +1. Create phone with `messages_per_minute: 10` (= 6s gap) |
| 217 | +2. Create phone API key + webhook |
| 218 | +3. Send 2 messages simultaneously |
| 219 | +4. Query WireMock journal for FCM pushes (correlate by message IDs from send responses) |
| 220 | +5. Assert the timestamps of the two FCM pushes have ≥6 second gap |
| 221 | +6. Fire SENT + DELIVERED for both messages |
| 222 | +7. Verify both messages reach `delivered` status |
| 223 | +8. Verify webhook events for both messages |
| 224 | +
|
| 225 | +#### `TestSendSMS_OutstandingFlow` |
| 226 | +
|
| 227 | +Validates the real phone flow (`/v1/messages/outstanding`): |
| 228 | +
|
| 229 | +1. Create phone + phone API key + webhook |
| 230 | +2. Send message via httpsms-go client |
| 231 | +3. Query WireMock journal → extract `KEY_MESSAGE_ID` from FCM push |
| 232 | +4. Call `GET /v1/messages/outstanding?message_id={id}` (phone API key) — assert returns the message with correct content, owner, contact |
| 233 | +5. Fire SENT + DELIVERED events |
| 234 | +6. Verify message reaches `delivered` status |
| 235 | +7. Verify webhook events |
| 236 | +
|
| 237 | +#### Webhook Verification (shared helper) |
| 238 | +
|
| 239 | +For all tests, a helper function: |
| 240 | +
|
| 241 | +```go |
| 242 | +func assertWebhookEvent(t *testing.T, wiremockClient *wiremock.Client, signingKey string, expectedEventType string) { |
| 243 | + // 1. Query WireMock journal for POST /webhooks/test requests |
| 244 | + // 2. Find request with X-Event-Type header matching expectedEventType |
| 245 | + // 3. Extract Authorization header → parse JWT |
| 246 | + // 4. Validate signature with signingKey (HMAC-SHA256) |
| 247 | + // 5. Assert claims: |
| 248 | + // - Issuer == "api.httpsms.com" |
| 249 | + // - Subject == "test-user-id" |
| 250 | + // - Audience contains webhook URL |
| 251 | + // - ExpiresAt is in the future |
| 252 | + // - NotBefore is in the past |
| 253 | +} |
| 254 | +``` |
| 255 | + |
| 256 | +### 6. Test Helper Structure |
| 257 | + |
| 258 | +``` |
| 259 | +tests/ |
| 260 | +├── docker-compose.yml (updated: wiremock replaces emulator) |
| 261 | +├── wiremock/ |
| 262 | +│ └── mappings/ |
| 263 | +│ ├── fcm-send.json |
| 264 | +│ ├── oauth-token.json |
| 265 | +│ └── webhook-receiver.json |
| 266 | +├── seed.sql (simplified: user + system user only) |
| 267 | +├── .env.test (updated: FCM_ENDPOINT → wiremock) |
| 268 | +├── go.mod (add httpsms-go, go-wiremock, golang-jwt) |
| 269 | +├── helpers_test.go (shared constants, setup helpers) |
| 270 | +├── webhook_helpers_test.go (JWT verification helpers) |
| 271 | +├── integration_test.go (all test cases) |
| 272 | +└── README.md |
| 273 | +``` |
| 274 | + |
| 275 | +### 7. Dependencies |
| 276 | + |
| 277 | +**Test module (`tests/go.mod`):** |
| 278 | + |
| 279 | +- `github.com/NdoleStudio/httpsms-go` — API client |
| 280 | +- `github.com/wiremock/go-wiremock` — WireMock stub configuration + journal queries |
| 281 | +- `github.com/golang-jwt/jwt/v5` — JWT parsing and validation |
| 282 | +- `github.com/stretchr/testify` — assertions (already present) |
| 283 | + |
| 284 | +### 8. Parallel Test Execution & Request Correlation |
| 285 | + |
| 286 | +Each test creates its own phone with a unique random number (e.g. `+1800555XXXX` where XXXX is random). This ensures: |
| 287 | + |
| 288 | +- No message cross-contamination between tests |
| 289 | +- Webhooks scoped to specific phone numbers don't fire for other tests |
| 290 | + |
| 291 | +**Correlation strategy for WireMock journal queries:** |
| 292 | + |
| 293 | +- **FCM pushes**: Correlate by message ID. The test gets the message ID from the send response, then searches WireMock journal for FCM push requests containing that `KEY_MESSAGE_ID` in the JSON body. |
| 294 | +- **Webhook events**: Each test uses a **unique webhook URL path** (e.g. `/webhooks/{testUUID}`). This ensures journal queries for webhook assertions only match events for that specific test. Additionally, match on `X-Event-Type` header and message ID in payload body. |
| 295 | +- **Unique FCM token per phone**: Each test generates a unique `fcm_token` string. Since WireMock captures the FCM push including the `token` field, this can be used as a secondary correlation key if needed. |
| 296 | + |
| 297 | +Tests use `t.Parallel()` where safe (encryption tests can run in parallel; rate-limit test may need serial execution due to timing assertions). |
| 298 | + |
| 299 | +## Migration Notes |
| 300 | + |
| 301 | +- The `tests/emulator/` directory is deleted entirely |
| 302 | +- The CI workflow (`.github/workflows/integration-test.yml`) needs updating to remove emulator references |
| 303 | +- Firebase credentials `token_uri` must point to `http://wiremock:8080/token` |
| 304 | +- WireMock image is Java-based (~300MB) vs the old Alpine emulator (~15MB), but eliminates maintenance of custom code |
0 commit comments