cast-ghl-plugin/.claude/tasks/10-testing.md
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

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