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>
6.2 KiB
6.2 KiB
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
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:
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:
// 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.
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)
#!/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