Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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>
197 lines
6.2 KiB
Markdown
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
|