package tests import ( "bytes" "context" "crypto/rand" "encoding/json" "fmt" "io" "math/big" "mime/multipart" "net/http" "strings" "testing" "time" httpsms "github.com/NdoleStudio/httpsms-go" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/wiremock/go-wiremock" wmJournal "github.com/wiremock/go-wiremock/journal" ) const ( apiBaseURL = "http://localhost:8000" wiremockURL = "http://localhost:8080" wiremockWebhookURL = "http://wiremock.local:8080" // reachable from API container, passes URL validation (needs a dot) userAPIKey = "test-user-api-key" ) type testPhone struct { PhoneNumber string PhoneAPIKey string FcmToken string } func newAPIClient() *httpsms.Client { return httpsms.New( httpsms.WithBaseURL(apiBaseURL), httpsms.WithAPIKey(userAPIKey), ) } func newPhoneClient(phoneAPIKey string) *httpsms.Client { return httpsms.New( httpsms.WithBaseURL(apiBaseURL), httpsms.WithAPIKey(phoneAPIKey), ) } func newWireMockClient() *wiremock.Client { return wiremock.NewClient(wiremockURL) } func randomPhoneNumber() string { n, err := rand.Int(rand.Reader, big.NewInt(10000)) if err != nil { return "+18005550000" } return fmt.Sprintf("+1800555%04d", n.Int64()) } func randomEncryptionKey() string { b := make([]byte, 16) _, err := rand.Read(b) if err != nil { return uuid.New().String() } return fmt.Sprintf("%x", b) } func setupPhone(ctx context.Context, t *testing.T, messagesPerMinute uint) testPhone { t.Helper() phoneNumber := randomPhoneNumber() fcmToken := "fcm-" + uuid.New().String() client := newAPIClient() // Create the phone API key first so that a few seconds pass (during phone upsert) // before we use it, giving the cache time to clear. apiKeyResp, resp, err := client.PhoneAPIKeys.Store(ctx, &httpsms.PhoneAPIKeyStoreParams{ Name: "test-key-" + uuid.New().String(), }) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.HTTPResponse.StatusCode, "phone api key store failed") phoneAPIKeyValue := apiKeyResp.Data.APIKey require.NotEmpty(t, phoneAPIKeyValue) _, resp, err = client.Phones.Upsert(ctx, &httpsms.PhoneUpsertParams{ PhoneNumber: phoneNumber, FcmToken: fcmToken, MessagesPerMinute: messagesPerMinute, MaxSendAttempts: 2, MessageExpirationSeconds: 600, SIM: "SIM1", }) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.HTTPResponse.StatusCode, "phone upsert failed") phoneClient := newPhoneClient(phoneAPIKeyValue) _, resp, err = phoneClient.Phones.UpsertFCMToken(ctx, &httpsms.PhoneFCMTokenParams{ PhoneNumber: phoneNumber, FcmToken: fcmToken, SIM: "SIM1", }) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.HTTPResponse.StatusCode, "fcm token bind failed") return testPhone{ PhoneNumber: phoneNumber, PhoneAPIKey: phoneAPIKeyValue, FcmToken: fcmToken, } } func setupWebhook(ctx context.Context, t *testing.T, phoneNumber string, events []string) (signingKey string, webhookPath string) { t.Helper() signingKey = randomEncryptionKey() webhookPath = "/webhooks/" + uuid.New().String() webhookURL := wiremockWebhookURL + webhookPath client := newAPIClient() _, resp, err := client.Webhooks.Store(ctx, &httpsms.WebhookStoreParams{ SigningKey: signingKey, URL: webhookURL, PhoneNumbers: []string{phoneNumber}, Events: events, }) require.NoError(t, err) require.Equal(t, http.StatusCreated, resp.HTTPResponse.StatusCode, "webhook store failed") return signingKey, webhookPath } func fireEvent(ctx context.Context, t *testing.T, phoneAPIKey string, messageID string, eventName string) { t.Helper() url := fmt.Sprintf("%s/v1/messages/%s/events", apiBaseURL, messageID) payload := map[string]interface{}{ "event_name": eventName, "timestamp": time.Now().UTC().Format(time.RFC3339), } body, err := json.Marshal(payload) require.NoError(t, err) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") req.Header.Set("x-api-key", phoneAPIKey) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode, "fire event %s failed: %s", eventName, string(respBody)) } func pollMessageStatus(ctx context.Context, t *testing.T, messageID string, targetStatus string, timeout time.Duration) httpsms.Message { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { url := fmt.Sprintf("%s/v1/messages/%s", apiBaseURL, messageID) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err == nil { req.Header.Set("x-api-key", userAPIKey) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err == nil { body, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr == nil && resp.StatusCode == http.StatusOK { var result httpsms.MessageResponse if json.Unmarshal(body, &result) == nil && result.Data.Status == targetStatus { return result.Data } } } } time.Sleep(500 * time.Millisecond) } t.Fatalf("message %s did not reach status %q within %v", messageID, targetStatus, timeout) return httpsms.Message{} } func fetchOutstandingMessage(ctx context.Context, t *testing.T, phoneAPIKey string, messageID string) map[string]interface{} { t.Helper() url := fmt.Sprintf("%s/v1/messages/outstanding?message_id=%s", apiBaseURL, messageID) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) require.NoError(t, err) req.Header.Set("x-api-key", phoneAPIKey) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode, "outstanding: %s", string(body)) var result map[string]interface{} require.NoError(t, json.Unmarshal(body, &result)) data, ok := result["data"].(map[string]interface{}) require.True(t, ok, "no data in outstanding response") return data } func findFCMRequests(t *testing.T, messageID string) []wmJournal.GetRequestResponse { t.Helper() wm := newWireMockClient() allReqs, err := wm.GetAllRequests() require.NoError(t, err) var matched []wmJournal.GetRequestResponse for _, req := range allReqs.Requests { if strings.Contains(req.Request.URL, "/messages:send") || strings.Contains(req.Request.AbsoluteURL, "/messages:send") { if strings.Contains(req.Request.Body, messageID) { matched = append(matched, req) } } } return matched } func findWebhookRequests(t *testing.T, webhookPath string) []wmJournal.GetRequestResponse { t.Helper() wm := newWireMockClient() allReqs, err := wm.GetAllRequests() require.NoError(t, err) var matched []wmJournal.GetRequestResponse for _, req := range allReqs.Requests { if strings.Contains(req.Request.URL, webhookPath) || strings.Contains(req.Request.AbsoluteURL, webhookPath) { matched = append(matched, req) } } return matched } func assertWebhookJWT(t *testing.T, request wmJournal.Request, signingKey string) { t.Helper() authHeader := request.Headers["Authorization"] if authHeader == "" { authHeader = request.Headers["authorization"] } require.NotEmpty(t, authHeader, "webhook request missing Authorization header") require.True(t, strings.HasPrefix(authHeader, "Bearer "), "Authorization header must start with Bearer") tokenString := strings.TrimPrefix(authHeader, "Bearer ") token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { require.Equal(t, jwt.SigningMethodHS256, token.Method, "unexpected signing method") return []byte(signingKey), nil }) require.NoError(t, err, "JWT validation failed") require.True(t, token.Valid, "JWT token is not valid") claims, ok := token.Claims.(jwt.MapClaims) require.True(t, ok, "cannot parse claims") require.Equal(t, "api.httpsms.com", claims["iss"], "issuer mismatch") require.NotEmpty(t, claims["sub"], "subject mismatch") exp, err := claims.GetExpirationTime() require.NoError(t, err) require.True(t, exp.After(time.Now()), "token is expired") nbf, err := claims.GetNotBefore() require.NoError(t, err) require.True(t, nbf.Before(time.Now()), "token not yet valid") } func waitForWebhookEvents(t *testing.T, webhookPath string, expectedCount int, timeout time.Duration) []wmJournal.GetRequestResponse { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { requests := findWebhookRequests(t, webhookPath) if len(requests) >= expectedCount { return requests } time.Sleep(500 * time.Millisecond) } requests := findWebhookRequests(t, webhookPath) require.GreaterOrEqual(t, len(requests), expectedCount, "expected at least %d webhook events on %s, got %d", expectedCount, webhookPath, len(requests)) return requests } func waitForFCMPush(t *testing.T, messageID string, timeout time.Duration) []wmJournal.GetRequestResponse { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { requests := findFCMRequests(t, messageID) if len(requests) >= 1 { return requests } time.Sleep(500 * time.Millisecond) } t.Fatalf("FCM push for message %s not found within %v", messageID, timeout) return nil } type BulkMessageEntry struct { RequestID string `json:"request_id"` Total int `json:"total"` ScheduledCount int `json:"scheduled_count"` PendingCount int `json:"pending_count"` FailedCount int `json:"failed_count"` ExpiredCount int `json:"expired_count"` SentCount int `json:"sent_count"` DeliveredCount int `json:"delivered_count"` CreatedAt string `json:"created_at"` } func uploadBulkFile(ctx context.Context, t *testing.T, filename string, fileBytes []byte) (int, []byte) { t.Helper() var buf bytes.Buffer writer := multipart.NewWriter(&buf) part, err := writer.CreateFormFile("document", filename) require.NoError(t, err) _, err = part.Write(fileBytes) require.NoError(t, err) require.NoError(t, writer.Close()) url := apiBaseURL + "/v1/bulk-messages" req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf) require.NoError(t, err) req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("x-api-key", userAPIKey) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) return resp.StatusCode, body } func fetchBulkMessages(ctx context.Context, t *testing.T) []BulkMessageEntry { t.Helper() url := apiBaseURL + "/v1/bulk-messages" req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) require.NoError(t, err) req.Header.Set("x-api-key", userAPIKey) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode, "fetch bulk messages failed: %s", string(body)) var result struct { Data []BulkMessageEntry `json:"data"` } require.NoError(t, json.Unmarshal(body, &result)) return result.Data } func searchMessages(ctx context.Context, t *testing.T, contact string, owner string) []httpsms.Message { t.Helper() url := fmt.Sprintf("%s/v1/messages?contact=%s&owner=%s&limit=20&skip=0", apiBaseURL, contact, owner) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) require.NoError(t, err) req.Header.Set("x-api-key", userAPIKey) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode, "search messages failed: %s", string(body)) var result struct { Data []httpsms.Message `json:"data"` } require.NoError(t, json.Unmarshal(body, &result)) return result.Data } func findBulkEntry(entries []BulkMessageEntry, requestID string) *BulkMessageEntry { for i := range entries { if entries[i].RequestID == requestID { return &entries[i] } } return nil }