package tests import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "testing" "time" httpsms "github.com/NdoleStudio/httpsms-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/xuri/excelize/v2" ) func TestSendSMS_Encrypted(t *testing.T) { ctx := context.Background() phone := setupPhone(ctx, t, 60) encryptionKey := randomEncryptionKey() signingKey, webhookPath := setupWebhook(ctx, t, phone.PhoneNumber, []string{ "message.phone.sent", "message.phone.delivered", }) client := newAPIClient() plaintext := "Hello encrypted world " + randomEncryptionKey() ciphertext, err := client.Cipher.Encrypt(encryptionKey, plaintext) require.NoError(t, err) require.NotEqual(t, plaintext, ciphertext) contactNumber := randomPhoneNumber() sendResp, resp, err := client.Messages.Send(ctx, &httpsms.MessageSendParams{ From: phone.PhoneNumber, To: contactNumber, Content: ciphertext, Encrypted: true, }) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.HTTPResponse.StatusCode) messageID := sendResp.Data.ID.String() require.NotEmpty(t, messageID) t.Logf("sent encrypted message: %s", messageID) fcmRequests := waitForFCMPush(t, messageID, 30*time.Second) require.Len(t, fcmRequests, 1) outstanding := fetchOutstandingMessage(ctx, t, phone.PhoneAPIKey, messageID) assert.Equal(t, true, outstanding["encrypted"]) assert.Equal(t, ciphertext, outstanding["content"]) assert.NotEqual(t, plaintext, outstanding["content"]) fireEvent(ctx, t, phone.PhoneAPIKey, messageID, "SENT") time.Sleep(200 * time.Millisecond) fireEvent(ctx, t, phone.PhoneAPIKey, messageID, "DELIVERED") msg := pollMessageStatus(ctx, t, messageID, "delivered", 30*time.Second) assert.Equal(t, "delivered", msg.Status) assert.True(t, msg.Encrypted) assert.Equal(t, ciphertext, msg.Content) decrypted, err := client.Cipher.Decrypt(encryptionKey, msg.Content) require.NoError(t, err) assert.Equal(t, plaintext, decrypted) webhookReqs := waitForWebhookEvents(t, webhookPath, 2, 30*time.Second) for _, req := range webhookReqs { assertWebhookJWT(t, req.Request, signingKey) } var eventTypes []string for _, req := range webhookReqs { if et, ok := req.Request.Headers["X-Event-Type"]; ok { eventTypes = append(eventTypes, et) } else if et, ok := req.Request.Headers["x-event-type"]; ok { eventTypes = append(eventTypes, et) } } assert.Contains(t, eventTypes, "message.phone.sent") assert.Contains(t, eventTypes, "message.phone.delivered") } func TestReceiveSMS_Encrypted(t *testing.T) { ctx := context.Background() phone := setupPhone(ctx, t, 60) encryptionKey := randomEncryptionKey() signingKey, webhookPath := setupWebhook(ctx, t, phone.PhoneNumber, []string{ "message.phone.received", }) client := newAPIClient() plaintext := "Incoming secret message " + randomEncryptionKey() ciphertext, err := client.Cipher.Encrypt(encryptionKey, plaintext) require.NoError(t, err) contactNumber := randomPhoneNumber() receivePayload := map[string]interface{}{ "from": contactNumber, "to": phone.PhoneNumber, "content": ciphertext, "encrypted": true, "sim": "SIM1", "timestamp": time.Now().UTC().Format(time.RFC3339), } body, err := json.Marshal(receivePayload) require.NoError(t, err) url := apiBaseURL + "/v1/messages/receive" 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", phone.PhoneAPIKey) httpResp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer httpResp.Body.Close() respBody, err := io.ReadAll(httpResp.Body) require.NoError(t, err) require.Equal(t, http.StatusOK, httpResp.StatusCode, "receive response: %s", string(respBody)) var receiveResult httpsms.MessageResponse require.NoError(t, json.Unmarshal(respBody, &receiveResult)) messageID := receiveResult.Data.ID.String() require.NotEmpty(t, messageID) t.Logf("received encrypted message: %s", messageID) msg := pollMessageStatus(ctx, t, messageID, "received", 15*time.Second) assert.Equal(t, "received", msg.Status) assert.True(t, msg.Encrypted) assert.Equal(t, ciphertext, msg.Content) assert.NotEqual(t, plaintext, msg.Content) decrypted, err := client.Cipher.Decrypt(encryptionKey, msg.Content) require.NoError(t, err) assert.Equal(t, plaintext, decrypted) webhookReqs := waitForWebhookEvents(t, webhookPath, 1, 30*time.Second) require.GreaterOrEqual(t, len(webhookReqs), 1) assertWebhookJWT(t, webhookReqs[0].Request, signingKey) eventType := webhookReqs[0].Request.Headers["X-Event-Type"] if eventType == "" { eventType = webhookReqs[0].Request.Headers["x-event-type"] } assert.Equal(t, "message.phone.received", eventType) } func TestSendSMS_RateLimit(t *testing.T) { ctx := context.Background() phone := setupPhone(ctx, t, 10) signingKey, webhookPath := setupWebhook(ctx, t, phone.PhoneNumber, []string{ "message.phone.sent", "message.phone.delivered", }) client := newAPIClient() contactNumber := randomPhoneNumber() sendResp1, resp1, err := client.Messages.Send(ctx, &httpsms.MessageSendParams{ From: phone.PhoneNumber, To: contactNumber, Content: "Rate limit test message 1", }) require.NoError(t, err) require.Equal(t, http.StatusOK, resp1.HTTPResponse.StatusCode) msgID1 := sendResp1.Data.ID.String() sendResp2, resp2, err := client.Messages.Send(ctx, &httpsms.MessageSendParams{ From: phone.PhoneNumber, To: contactNumber, Content: "Rate limit test message 2", }) require.NoError(t, err) require.Equal(t, http.StatusOK, resp2.HTTPResponse.StatusCode) msgID2 := sendResp2.Data.ID.String() t.Logf("sent messages: %s, %s", msgID1, msgID2) fcm1 := waitForFCMPush(t, msgID1, 30*time.Second) require.Len(t, fcm1, 1) fcm2 := waitForFCMPush(t, msgID2, 30*time.Second) require.Len(t, fcm2, 1) time1 := fcm1[0].Request.LoggedDate time2 := fcm2[0].Request.LoggedDate gapMs := time2 - time1 if gapMs < 0 { gapMs = time1 - time2 } t.Logf("FCM push gap: %dms", gapMs) assert.GreaterOrEqual(t, gapMs, int64(5500), "rate limit gap should be >= 5500ms (6s minus timing tolerance), got %dms", gapMs) fireEvent(ctx, t, phone.PhoneAPIKey, msgID1, "SENT") fireEvent(ctx, t, phone.PhoneAPIKey, msgID1, "DELIVERED") fireEvent(ctx, t, phone.PhoneAPIKey, msgID2, "SENT") fireEvent(ctx, t, phone.PhoneAPIKey, msgID2, "DELIVERED") msg1 := pollMessageStatus(ctx, t, msgID1, "delivered", 15*time.Second) msg2 := pollMessageStatus(ctx, t, msgID2, "delivered", 15*time.Second) assert.Equal(t, "delivered", msg1.Status) assert.Equal(t, "delivered", msg2.Status) webhookReqs := waitForWebhookEvents(t, webhookPath, 4, 30*time.Second) for _, req := range webhookReqs { assertWebhookJWT(t, req.Request, signingKey) } } func TestRotateAPIKey_InvalidatesCache(t *testing.T) { ctx := context.Background() // Use a dedicated test user so we don't mutate the shared userAPIKey rotateUserAPIKey := "rotate-test-api-key" rotateUserID := "rotate-test-user-id" // 1) Confirm the dedicated user's API key works and warm the cache meURL := apiBaseURL + "/v1/users/me" req, err := http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil) require.NoError(t, err) req.Header.Set("x-api-key", rotateUserAPIKey) 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, "initial auth failed: %s", string(body)) // Parse the current API key from the response var meResp struct { Data struct { ID string `json:"id"` APIKey string `json:"api_key"` } `json:"data"` } require.NoError(t, json.Unmarshal(body, &meResp)) require.Equal(t, rotateUserID, meResp.Data.ID) oldAPIKey := meResp.Data.APIKey require.NotEmpty(t, oldAPIKey) t.Logf("user ID: %s, old API key prefix: %s...", rotateUserID, oldAPIKey[:10]) // 2) Rotate the API key rotateURL := fmt.Sprintf("%s/v1/users/%s/api-keys", apiBaseURL, rotateUserID) req, err = http.NewRequestWithContext(ctx, http.MethodDelete, rotateURL, nil) require.NoError(t, err) req.Header.Set("x-api-key", rotateUserAPIKey) 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, "rotate failed: %s", string(body)) // Parse new API key from rotate response var rotateResp struct { Data struct { APIKey string `json:"api_key"` } `json:"data"` } require.NoError(t, json.Unmarshal(body, &rotateResp)) newAPIKey := rotateResp.Data.APIKey require.NotEmpty(t, newAPIKey) require.NotEqual(t, oldAPIKey, newAPIKey, "API key should have changed after rotation") t.Logf("new API key prefix: %s...", newAPIKey[:10]) // 3) Old API key should immediately fail (401) — this is the bug regression check req, err = http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil) require.NoError(t, err) req.Header.Set("x-api-key", oldAPIKey) resp, err = http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "old API key should return 401 after rotation") // 4) New API key should work req, err = http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil) require.NoError(t, err) req.Header.Set("x-api-key", newAPIKey) resp, err = http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() body, err = io.ReadAll(resp.Body) require.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode, "new API key should work: %s", string(body)) } func TestSendSMS_OutstandingFlow(t *testing.T) { ctx := context.Background() phone := setupPhone(ctx, t, 60) signingKey, webhookPath := setupWebhook(ctx, t, phone.PhoneNumber, []string{ "message.phone.sent", "message.phone.delivered", }) client := newAPIClient() contactNumber := randomPhoneNumber() content := "Outstanding flow test " + randomEncryptionKey() sendResp, resp, err := client.Messages.Send(ctx, &httpsms.MessageSendParams{ From: phone.PhoneNumber, To: contactNumber, Content: content, }) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.HTTPResponse.StatusCode) messageID := sendResp.Data.ID.String() t.Logf("sent message: %s", messageID) fcmReqs := waitForFCMPush(t, messageID, 30*time.Second) require.Len(t, fcmReqs, 1) assert.Contains(t, fcmReqs[0].Request.Body, messageID) assert.True(t, strings.Contains(fcmReqs[0].Request.URL, "/messages:send") || strings.Contains(fcmReqs[0].Request.AbsoluteURL, "/messages:send")) outstanding := fetchOutstandingMessage(ctx, t, phone.PhoneAPIKey, messageID) assert.Equal(t, messageID, outstanding["id"]) assert.Equal(t, content, outstanding["content"]) assert.Equal(t, phone.PhoneNumber, outstanding["owner"]) assert.Equal(t, contactNumber, outstanding["contact"]) fireEvent(ctx, t, phone.PhoneAPIKey, messageID, "SENT") time.Sleep(200 * time.Millisecond) fireEvent(ctx, t, phone.PhoneAPIKey, messageID, "DELIVERED") msg := pollMessageStatus(ctx, t, messageID, "delivered", 30*time.Second) assert.Equal(t, "delivered", msg.Status) assert.Equal(t, content, msg.Content) webhookReqs := waitForWebhookEvents(t, webhookPath, 2, 30*time.Second) for _, req := range webhookReqs { assertWebhookJWT(t, req.Request, signingKey) } } func TestHeartbeat_StoreAndIndex(t *testing.T) { ctx := context.Background() phone := setupPhone(ctx, t, 60) // Store a heartbeat via phone API key (retry to allow async phone-API-key association) storePayload := map[string]interface{}{ "phone_numbers": []string{phone.PhoneNumber}, "charging": true, } url := apiBaseURL + "/v1/heartbeats" var respBody []byte var statusCode int deadline := time.Now().Add(15 * time.Second) for time.Now().Before(deadline) { body, err := json.Marshal(storePayload) 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", phone.PhoneAPIKey) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) respBody, err = io.ReadAll(resp.Body) resp.Body.Close() require.NoError(t, err) statusCode = resp.StatusCode if statusCode == http.StatusCreated { break } time.Sleep(500 * time.Millisecond) } require.Equal(t, http.StatusCreated, statusCode, "store heartbeat failed: %s", string(respBody)) // Read heartbeats back via user API key client := newAPIClient() heartbeats, indexResp, err := client.Heartbeats.Index(ctx, &httpsms.HeartbeatIndexParams{ Owner: phone.PhoneNumber, Limit: 1, }) require.NoError(t, err) require.Equal(t, http.StatusOK, indexResp.HTTPResponse.StatusCode) require.NotNil(t, heartbeats) require.GreaterOrEqual(t, len(heartbeats.Data), 1, "expected at least 1 heartbeat") hb := heartbeats.Data[0] assert.Equal(t, phone.PhoneNumber, hb.Owner) assert.True(t, hb.Charging) assert.False(t, hb.Timestamp.IsZero(), "timestamp should not be zero") } func TestBulkSMS_CSV(t *testing.T) { ctx := context.Background() phone := setupPhone(ctx, t, 60) // Build CSV content with 1 message contact := randomPhoneNumber() csvContent := fmt.Sprintf("FromPhoneNumber,ToPhoneNumber,Content,SendTime(optional)\n%s,%s,CSV bulk test message,\n", phone.PhoneNumber, contact) // Upload CSV statusCode, respBody := uploadBulkFile(ctx, t, "test.csv", []byte(csvContent)) require.Equal(t, http.StatusAccepted, statusCode, "upload failed: %s", string(respBody)) t.Logf("upload response: %s", string(respBody)) // Parse the response to verify message count var uploadResp struct { Message string `json:"message"` } require.NoError(t, json.Unmarshal(respBody, &uploadResp)) assert.Contains(t, uploadResp.Message, "1 out of 1") // Wait a moment for messages to be persisted time.Sleep(2 * time.Second) // Search for the bulk message by owner to get message IDs messages := searchMessages(ctx, t, contact, phone.PhoneNumber) require.GreaterOrEqual(t, len(messages), 1, "expected at least 1 message for phone %s", phone.PhoneNumber) // Find the message with bulk- request_id prefix var bulkMsg *httpsms.Message for i := range messages { if messages[i].RequestID != nil && strings.HasPrefix(*messages[i].RequestID, "bulk-") { bulkMsg = &messages[i] break } } require.NotNil(t, bulkMsg, "no message with bulk- request_id found") messageID := bulkMsg.ID.String() requestID := *bulkMsg.RequestID t.Logf("found bulk message: id=%s, request_id=%s", messageID, requestID) // Wait for FCM push waitForFCMPush(t, messageID, 30*time.Second) // Fire SENT event fireEvent(ctx, t, phone.PhoneAPIKey, messageID, "SENT") // Poll until message reaches "sent" status msg := pollMessageStatus(ctx, t, messageID, "sent", 15*time.Second) assert.Equal(t, "sent", msg.Status) // Verify bulk-messages history endpoint entries := fetchBulkMessages(ctx, t) entry := findBulkEntry(entries, requestID) require.NotNil(t, entry, "bulk entry with request_id %s not found in history", requestID) assert.Equal(t, 1, entry.Total) assert.Equal(t, 1, entry.SentCount) assert.Equal(t, 0, entry.PendingCount) assert.Equal(t, 0, entry.FailedCount) assert.Equal(t, 0, entry.ExpiredCount) assert.Equal(t, 0, entry.DeliveredCount) assert.Equal(t, 0, entry.ScheduledCount) } func TestBulkSMS_Excel(t *testing.T) { ctx := context.Background() phone := setupPhone(ctx, t, 60) contact1 := randomPhoneNumber() contact2 := randomPhoneNumber() // Build Excel file with 2 messages f := excelize.NewFile() sheet := f.GetSheetName(0) f.SetCellValue(sheet, "A1", "FromPhoneNumber") f.SetCellValue(sheet, "B1", "ToPhoneNumber") f.SetCellValue(sheet, "C1", "Content") f.SetCellValue(sheet, "D1", "SendTime(optional)") f.SetCellValue(sheet, "A2", phone.PhoneNumber) f.SetCellValue(sheet, "B2", contact1) f.SetCellValue(sheet, "C2", "Excel bulk test message 1") f.SetCellValue(sheet, "D2", "") f.SetCellValue(sheet, "A3", phone.PhoneNumber) f.SetCellValue(sheet, "B3", contact2) f.SetCellValue(sheet, "C3", "Excel bulk test message 2") f.SetCellValue(sheet, "D3", "") var excelBuf bytes.Buffer require.NoError(t, f.Write(&excelBuf)) // Upload Excel statusCode, respBody := uploadBulkFile(ctx, t, "test.xlsx", excelBuf.Bytes()) require.Equal(t, http.StatusAccepted, statusCode, "upload failed: %s", string(respBody)) t.Logf("upload response: %s", string(respBody)) var uploadResp struct { Message string `json:"message"` } require.NoError(t, json.Unmarshal(respBody, &uploadResp)) assert.Contains(t, uploadResp.Message, "2 out of 2") // Wait for messages to be persisted time.Sleep(2 * time.Second) // Search for bulk messages by owner and each contact messages1 := searchMessages(ctx, t, contact1, phone.PhoneNumber) messages2 := searchMessages(ctx, t, contact2, phone.PhoneNumber) messages := append(messages1, messages2...) require.GreaterOrEqual(t, len(messages), 2, "expected at least 2 messages for phone %s", phone.PhoneNumber) // Find messages with bulk- request_id prefix var bulkMessages []httpsms.Message var requestID string for i := range messages { if messages[i].RequestID != nil && strings.HasPrefix(*messages[i].RequestID, "bulk-") { bulkMessages = append(bulkMessages, messages[i]) requestID = *messages[i].RequestID } } require.Len(t, bulkMessages, 2, "expected 2 messages with bulk- request_id") require.NotEmpty(t, requestID) t.Logf("found %d bulk messages with request_id=%s", len(bulkMessages), requestID) // Wait for FCM pushes for both messages msgID1 := bulkMessages[0].ID.String() msgID2 := bulkMessages[1].ID.String() waitForFCMPush(t, msgID1, 30*time.Second) waitForFCMPush(t, msgID2, 30*time.Second) // Fire SENT then DELIVERED on message 1, leave message 2 pending fireEvent(ctx, t, phone.PhoneAPIKey, msgID1, "SENT") time.Sleep(200 * time.Millisecond) fireEvent(ctx, t, phone.PhoneAPIKey, msgID1, "DELIVERED") // Poll until message 1 reaches "delivered" msg1 := pollMessageStatus(ctx, t, msgID1, "delivered", 15*time.Second) assert.Equal(t, "delivered", msg1.Status) // Poll until message 2 reaches "scheduled" (FCM push sent but no SENT event fired) msg2 := pollMessageStatus(ctx, t, msgID2, "scheduled", 15*time.Second) assert.Equal(t, "scheduled", msg2.Status) // Verify bulk-messages history endpoint entries := fetchBulkMessages(ctx, t) entry := findBulkEntry(entries, requestID) require.NotNil(t, entry, "bulk entry with request_id %s not found in history", requestID) assert.Equal(t, 2, entry.Total) assert.Equal(t, 1, entry.DeliveredCount) assert.Equal(t, 0, entry.PendingCount) assert.Equal(t, 0, entry.SentCount) assert.Equal(t, 0, entry.FailedCount) assert.Equal(t, 0, entry.ExpiredCount) assert.Equal(t, 1, entry.ScheduledCount) }