Cast AID Agent 60785a52f5 Add SMS API load testing service for Cast
Go service that load tests the Cast SMS/OTP API with configurable
concurrent threads, target RPS, ramp-up period, and message parts.
Features live stats, final report with SMS parts tracking, and
config warnings for suboptimal test parameters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:19:20 +01:00

155 lines
4.0 KiB
Go

package sender
import (
"bytes"
"encoding/json"
"io"
"log"
"math/rand"
"net/http"
"strings"
"time"
"cast-loadtest/internal/config"
"cast-loadtest/internal/stats"
)
type SMSPayload struct {
Destination string `json:"destination"`
Message string `json:"message"`
SenderID string `json:"sender_id,omitempty"`
}
var poemLines = []string{
"The sun sets low on distant hills,",
"A river hums and slowly spills,",
"The stars align in silver rows,",
"A gentle wind forever blows,",
"Through ancient woods the shadows creep,",
"Where mountains rise and valleys sleep,",
"The moon ascends with quiet grace,",
"And lights the earth from outer space,",
"A bird sings out its evening call,",
"While autumn leaves begin to fall,",
"The ocean roars with timeless might,",
"Beneath the cloak of endless night,",
"A flame ignites in frozen lands,",
"Where time slips through like grains of sand,",
"The dawn arrives on golden wings,",
"And with it hope and promise brings,",
"A flower blooms in morning dew,",
"Its petals kissed by skies of blue,",
"The thunder rolls across the plain,",
"A world reborn through summer rain,",
"The forest whispers ancient tales,",
"Of ships that sailed through mighty gales,",
"A lighthouse stands on rocky shores,",
"Its beacon bright forevermore,",
"The snow falls soft on silent ground,",
"A hush descends without a sound,",
"The wolf howls at the crescent moon,",
"A wanderer hums a broken tune,",
"The clock ticks on through dust and age,",
"Each moment writes another page,",
}
func NewHTTPClient(threads int) *http.Client {
return &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
MaxIdleConns: threads * 2,
MaxIdleConnsPerHost: threads * 2,
IdleConnTimeout: 90 * time.Second,
},
}
}
func SendSMS(client *http.Client, cfg config.Config, st *stats.Stats) {
payload := SMSPayload{
Destination: cfg.Recipient,
Message: generateMessage(cfg.MsgParts),
SenderID: cfg.SenderID,
}
body, err := json.Marshal(payload)
if err != nil {
st.FailCount.Add(1)
st.TotalRequests.Add(1)
log.Printf("ERROR marshal: %v", err)
return
}
req, err := http.NewRequest("POST", cfg.APIURL, bytes.NewReader(body))
if err != nil {
st.FailCount.Add(1)
st.TotalRequests.Add(1)
log.Printf("ERROR request: %v", err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", cfg.APIKey)
start := time.Now()
resp, err := client.Do(req)
latencyMs := time.Since(start).Milliseconds()
st.TotalRequests.Add(1)
st.RecordLatency(latencyMs)
if err != nil {
st.FailCount.Add(1)
log.Printf("ERROR send: %v (latency: %dms)", err, latencyMs)
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
st.RecordStatusCode(resp.StatusCode)
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
st.SuccessCount.Add(1)
// Parse response to extract SMS parts
var apiResp struct {
Parts int `json:"parts"`
}
if json.Unmarshal(respBody, &apiResp) == nil && apiResp.Parts > 0 {
st.TotalSMSParts.Add(int64(apiResp.Parts))
}
} else {
st.FailCount.Add(1)
log.Printf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
}
// generateMessage builds a message sized to fit within the target SMS parts.
// GSM-7: single part = 160 chars, multipart = 153 chars/part.
// Square brackets cost 2 chars each in GSM-7, so we use "LOADTEST:" instead.
// We cap at 140 chars/part to leave safe headroom.
func generateMessage(parts int) string {
prefix := "LOADTEST: "
maxChars := parts * 140
budget := maxChars - len(prefix)
// Shuffle poem lines and pick lines that fit within budget
lines := make([]string, len(poemLines))
copy(lines, poemLines)
rand.Shuffle(len(lines), func(i, j int) { lines[i], lines[j] = lines[j], lines[i] })
var b strings.Builder
for _, line := range lines {
needed := len(line)
if b.Len() > 0 {
needed++ // space separator
}
if b.Len()+needed > budget {
break
}
if b.Len() > 0 {
b.WriteByte(' ')
}
b.WriteString(line)
}
return prefix + b.String()
}