Head of Product & Engineering a40a4aa626
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
feat: initial implementation of Cast GHL Provider
Complete MVP implementation of the Cast GHL Conversation Provider bridge:
- Go module setup with chi router and mongo-driver dependencies
- Config loading with env var validation and defaults
- MongoDB token store with upsert, get, update, delete operations
- Cast.ph SMS client with 429 retry logic and typed errors
- Phone number normalization (E.164 ↔ Philippine local format)
- GHL OAuth 2.0 install/callback/refresh flow
- GHL webhook handler with ECDSA signature verification (async dispatch)
- GHL API client for message status updates and inbound message stubs
- Multi-stage Dockerfile, docker-compose with MongoDB, Woodpecker CI pipeline
- Unit tests for phone normalization, Cast client, GHL webhook, and OAuth handlers

Co-Authored-By: SideKx <sidekx.ai@sds.dev>
2026-04-04 17:27:05 +02:00

197 lines
6.2 KiB
Markdown

# Task 10: Testing
## Objective
Unit tests for all packages + an integration smoke test.
## Part A: Phone normalization tests (`internal/phone/normalize_test.go`)
Already specified in Task 04. Ensure all cases pass.
## Part B: Cast client tests (`internal/cast/client_test.go`)
Use `httptest.NewServer` to mock the Cast API.
| Test | What to verify |
|------|----------------|
| SendSMS success (200) | Correct URL path, headers, body |
| SendSMS Cast error (402) | Returns `CastAPIError` with correct fields |
| SendSMS success:false in body | Returns `CastAPIError` with API error message |
| SendSMS with sender_id | `sender_id` present in JSON body |
| SendSMS without sender_id | `sender_id` omitted from JSON body |
| X-API-Key header | Present on every request |
| Retry on 429 | Mock returns 429 twice then 200 — verify 3 calls total |
### Mock pattern
```go
func TestSendSMS_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify request
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/sms/send", r.URL.Path)
assert.Equal(t, "cast_testkey", r.Header.Get("X-API-Key"))
var body cast.SendRequest
json.NewDecoder(r.Body).Decode(&body)
assert.Equal(t, "09171234567", body.To)
// Return success
w.WriteHeader(200)
json.NewEncoder(w).Encode(cast.SendResponse{
Success: true,
MessageID: "abc123",
Parts: 1,
})
}))
defer srv.Close()
client := cast.NewClient(srv.URL, "cast_testkey", "")
resp, err := client.SendSMS(context.Background(), "09171234567", "test message")
assert.NoError(t, err)
assert.True(t, resp.Success)
assert.Equal(t, "abc123", resp.MessageID)
}
```
## Part C: GHL webhook tests (`internal/ghl/webhook_test.go`)
### Signature verification tests
Generate a test ECDSA key pair in the test:
```go
func generateTestKeyPair(t *testing.T) (*ecdsa.PrivateKey, string) {
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
pubBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey)
require.NoError(t, err)
pemBlock := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
return privKey, string(pemBlock)
}
func signPayload(t *testing.T, privKey *ecdsa.PrivateKey, body []byte) string {
hash := sha256.Sum256(body)
sig, err := ecdsa.SignASN1(rand.Reader, privKey, hash[:])
require.NoError(t, err)
return base64.StdEncoding.EncodeToString(sig)
}
```
| Test | What to verify |
|------|----------------|
| Valid signature, SMS type | Returns 200, Cast SendSMS called |
| Invalid signature | Returns 401 |
| Missing signature header | Returns 401 |
| Non-SMS type | Returns 200, Cast SendSMS NOT called |
| Valid webhook, Cast fails | GHL status updated to "failed" |
| Valid webhook, Cast succeeds | GHL status updated to "delivered" |
| Phone normalization failure | GHL status updated to "failed" |
### Mock Cast client and GHL API
Create interfaces or use function fields for testability:
```go
// In tests, inject mock functions
webhookHandler.castClient = &mockCastClient{
sendSMSFunc: func(ctx context.Context, to, message string) (*cast.SendResponse, error) {
return &cast.SendResponse{Success: true, MessageID: "test123", Parts: 1}, nil
},
}
```
## Part D: OAuth tests (`internal/ghl/oauth_test.go`)
| Test | What to verify |
|------|----------------|
| HandleInstall | Redirects (302) to correct GHL URL with all scopes |
| HandleCallback no code | Returns 400 |
| HandleCallback success | Exchanges code, stores token |
| RefreshToken | Sends refresh_token grant, updates store |
| GetValidToken not expired | Returns stored token without refresh |
| GetValidToken near expiry | Triggers refresh, returns new token |
Use `httptest.NewServer` to mock the GHL token endpoint.
## Part E: Store tests (`internal/store/mongo_test.go`)
**Note:** These require a running MongoDB instance. Skip in CI if no MongoDB available.
```go
func TestStore(t *testing.T) {
if os.Getenv("MONGO_TEST_URI") == "" {
t.Skip("MONGO_TEST_URI not set, skipping store tests")
}
// ...
}
```
| Test | What to verify |
|------|----------------|
| SaveToken + GetToken | Round-trip works |
| SaveToken upsert | Second save updates, doesn't duplicate |
| GetToken not found | Returns nil, nil |
| UpdateToken | Updates fields + updated_at |
| DeleteToken | Record no longer found |
## Part F: Integration smoke test (`scripts/smoke-test.sh`)
```bash
#!/usr/bin/env bash
set -euo pipefail
echo "=== Build ==="
go build -o /tmp/cast-ghl-provider ./cmd/server/
echo "PASS: build"
echo "=== Health check ==="
# Start server in background with minimal config
export PORT=13002
export BASE_URL=http://localhost:13002
export GHL_CLIENT_ID=test
export GHL_CLIENT_SECRET=test
export GHL_WEBHOOK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEtest...
-----END PUBLIC KEY-----"
export GHL_CONVERSATION_PROVIDER_ID=test
export CAST_API_KEY=cast_test
export MONGO_URI=mongodb://localhost:27017/cast-ghl-test
/tmp/cast-ghl-provider &
SERVER_PID=$!
sleep 2
# Health check
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:13002/health)
if [ "$HTTP_CODE" = "200" ]; then
echo "PASS: health check"
else
echo "FAIL: health check returned $HTTP_CODE"
kill $SERVER_PID 2>/dev/null
exit 1
fi
# Install redirect
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -L --max-redirs 0 http://localhost:13002/install)
if [ "$HTTP_CODE" = "302" ]; then
echo "PASS: install redirect"
else
echo "FAIL: install returned $HTTP_CODE (expected 302)"
fi
# Cleanup
kill $SERVER_PID 2>/dev/null
echo "=== All smoke tests passed ==="
```
## Acceptance Criteria
- [ ] `go test ./...` passes
- [ ] Phone normalization: all table-driven cases pass
- [ ] Cast client: success, error, retry, header tests pass
- [ ] Webhook: signature verify, SMS dispatch, status update tests pass
- [ ] OAuth: install redirect, callback, refresh tests pass
- [ ] No real HTTP calls to Cast or GHL in any test
- [ ] Store tests skip gracefully when no MongoDB
- [ ] Smoke test verifies health + install redirect