Files
doormile_backend/internal/sms/http_sender_test.go
2026-09-16 11:42:06 +05:30

187 lines
5.5 KiB
Go

package sms
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// The gateway that closes the sign-in blocker.
//
// sms.Register had no callers anywhere in the tree, so logSender was never
// replaced and every customer verification code went to the application log
// instead of to a phone. That is why nobody could sign in, why the app's
// offline dev mode became the only practical way in, and why bookings "made"
// in that mode never reached the admin console.
func restoreSender(t *testing.T) {
t.Helper()
previous := active
t.Cleanup(func() { active = previous })
}
func testSender(url, bodyTmpl string) httpSender {
if bodyTmpl == "" {
bodyTmpl = `{"to":"{{phone}}","message":"{{message}}","sender":"{{sender}}"}`
}
return httpSender{
url: url,
method: http.MethodPost,
bodyTmpl: bodyTmpl,
contentType: "application/json",
senderID: "DRMILE",
client: &http.Client{Timeout: 5 * time.Second},
}
}
// The destination and the code have to actually reach the gateway.
func TestGatewaySendsPhoneAndCode(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
got = string(b)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
restoreSender(t)
Register(testSender(srv.URL, ""))
if err := SendOTP("+919876543210", "4821"); err != nil {
t.Fatalf("SendOTP: %v", err)
}
for _, want := range []string{"+919876543210", "4821", "DRMILE"} {
if !strings.Contains(got, want) {
t.Errorf("gateway body %q is missing %q", got, want)
}
}
}
// A refused send — no balance, unapproved DLT template — must surface as an
// error. Swallowing it tells the customer a code is on its way when it is not,
// which is precisely the failure logSender has been producing all along.
func TestGatewayRefusalIsReported(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusPaymentRequired)
_, _ = w.Write([]byte(`{"error":"insufficient balance"}`))
}))
defer srv.Close()
restoreSender(t)
Register(testSender(srv.URL, ""))
if err := SendOTP("+919876543210", "4821"); err == nil {
t.Error("a rejected send reported success — the customer would wait for a " +
"text that is never coming")
}
}
// An unreachable gateway is an error, not a silent no-op.
func TestUnreachableGatewayIsReported(t *testing.T) {
restoreSender(t)
Register(testSender("http://127.0.0.1:1/unreachable", ""))
if err := SendOTP("+919876543210", "4821"); err == nil {
t.Error("an unreachable gateway reported success")
}
}
// A quote in the message must not break a JSON body template.
func TestMessageIsEscapedForJSON(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
got = string(b)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
restoreSender(t)
Register(testSender(srv.URL, ""))
if err := active.Send("+919876543210", `say "hello"`); err != nil {
t.Fatalf("send: %v", err)
}
if strings.Contains(got, `say "hello"`) {
t.Errorf("an unescaped quote reached the JSON body: %q", got)
}
if !strings.Contains(got, `say \"hello\"`) {
t.Errorf("the message was not escaped as expected: %q", got)
}
}
// Vendors differ; the template is what makes one sender cover all of them.
func TestBodyTemplateIsVendorAgnostic(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
got = string(b)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
restoreSender(t)
Register(testSender(srv.URL, "mobiles={{phone}}&message={{message}}&sender={{sender}}"))
if err := SendOTP("+919876543210", "4821"); err != nil {
t.Fatalf("send: %v", err)
}
if !strings.HasPrefix(got, "mobiles=+919876543210&message=") {
t.Errorf("form-encoded template not honoured: %q", got)
}
}
// Configure is what gives sms.Register its first caller. With no URL it must
// leave the log sink exactly where it is, so this change cannot break a
// deployment that has not been configured yet.
func TestConfigureLeavesTheLogSinkWhenUnset(t *testing.T) {
restoreSender(t)
active = logSender{}
setEnv(t, "SMS_GATEWAY_URL", "")
Configure()
if Configured() {
t.Error("Configure installed a gateway with no SMS_GATEWAY_URL set")
}
if Transport() != "log" {
t.Errorf("Transport() = %q, want \"log\"", Transport())
}
}
func TestConfigureInstallsTheGatewayWhenSet(t *testing.T) {
restoreSender(t)
active = logSender{}
setEnv(t, "SMS_GATEWAY_URL", "https://sms.example.invalid/send")
Configure()
if !Configured() {
t.Fatal("Configure did not install a gateway despite SMS_GATEWAY_URL being set")
}
if Transport() != "http-gateway" {
t.Errorf("Transport() = %q, want \"http-gateway\"", Transport())
}
}
// In production the log sink must refuse rather than write a live credential
// to the log and report success.
func TestLogSenderRefusesInProduction(t *testing.T) {
restoreSender(t)
active = logSender{}
setEnv(t, "ENV", "production")
if err := SendOTP("+919876543210", "4821"); err == nil {
t.Error("with no gateway in production, SendOTP reported success — the code " +
"went to the log and the customer was told it was sent")
}
setEnv(t, "ENV", "development")
if err := SendOTP("+919876543210", "4821"); err != nil {
t.Errorf("outside production the log sink must still work for QA: %v", err)
}
}