Skip to content

Commit e58acfd

Browse files
AchoArnoldCopilot
andcommitted
docs: add integration tests WireMock refactor design spec
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 74e3f4e commit e58acfd

1 file changed

Lines changed: 304 additions & 0 deletions

File tree

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
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

Comments
 (0)