# 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