move over

This commit is contained in:
KS Jannette
2026-02-27 16:41:18 -05:00
parent 5f81a4b1cc
commit 35d1c13d0a
54 changed files with 312 additions and 5626 deletions

View File

@@ -30,6 +30,7 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
Label *string `json:"label"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("Failed to decode address request body: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"net/http"
"strconv"
"strings"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
@@ -30,15 +31,23 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
}
var body struct {
Type string `json:"type"`
Threshold *float64 `json:"threshold"`
Type string `json:"type"`
Threshold json.RawMessage `json:"threshold"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("Failed to decode alert request body: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return
}
log.Printf("User %s creating alert for address ID: %d", userID, addressID)
threshold, err := parseThreshold(body.Threshold)
if err != nil {
log.Printf("Failed to parse threshold: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "threshold must be a valid number")
return
}
log.Printf("User %s creating alert: type=%s, addressID=%d", userID, body.Type, addressID)
if body.Type == "" {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Alert type is required")
@@ -57,7 +66,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
alertType := domain.AlertType(body.Type)
if domain.IsThresholdRequired(alertType) {
if body.Threshold == nil || *body.Threshold <= 0 {
if threshold == nil || *threshold <= 0 {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
fmt.Sprintf("Alert type '%s' requires a positive threshold value", body.Type))
return
@@ -76,7 +85,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
return
}
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, body.Threshold)
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold)
if err != nil {
log.Printf("Error creating alert rule: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
@@ -87,6 +96,37 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, newAlert)
}
func parseThreshold(raw json.RawMessage) (*float64, error) {
if len(raw) == 0 {
return nil, nil
}
// Check null before number -- json.Unmarshal treats null as valid for float64 (sets to 0).
if string(raw) == "null" {
return nil, nil
}
var asNumber float64
if err := json.Unmarshal(raw, &asNumber); err == nil {
return &asNumber, nil
}
var asString string
if err := json.Unmarshal(raw, &asString); err == nil {
asString = strings.TrimSpace(asString)
if asString == "" {
return nil, nil
}
parsed, parseErr := strconv.ParseFloat(asString, 64)
if parseErr != nil {
return nil, parseErr
}
return &parsed, nil
}
return nil, fmt.Errorf("unsupported threshold format")
}
func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
addressID, ok := parseIntParam(r.PathValue("addressId"))
@@ -136,6 +176,7 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("Failed to decode update request body: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return
}

View File

@@ -0,0 +1,214 @@
package handlers
import (
"encoding/json"
"math"
"testing"
)
func TestParseThreshold(t *testing.T) {
t.Run("nil/empty raw message returns nil", func(t *testing.T) {
val, err := parseThreshold(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != nil {
t.Fatalf("expected nil, got %v", *val)
}
})
t.Run("empty slice returns nil", func(t *testing.T) {
val, err := parseThreshold(json.RawMessage{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != nil {
t.Fatalf("expected nil, got %v", *val)
}
})
t.Run("JSON null returns nil", func(t *testing.T) {
val, err := parseThreshold(json.RawMessage("null"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != nil {
t.Fatalf("expected nil, got %v", *val)
}
})
t.Run("number 10 returns 10.0", func(t *testing.T) {
val, err := parseThreshold(json.RawMessage("10"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val == nil {
t.Fatal("expected non-nil value")
}
if *val != 10.0 {
t.Fatalf("expected 10.0, got %v", *val)
}
})
t.Run("number 0.5 returns 0.5", func(t *testing.T) {
val, err := parseThreshold(json.RawMessage("0.5"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val == nil || *val != 0.5 {
t.Fatalf("expected 0.5, got %v", val)
}
})
t.Run("string '10' returns 10.0", func(t *testing.T) {
val, err := parseThreshold(json.RawMessage(`"10"`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val == nil || *val != 10.0 {
t.Fatalf("expected 10.0, got %v", val)
}
})
t.Run("string '0.001' returns 0.001", func(t *testing.T) {
val, err := parseThreshold(json.RawMessage(`"0.001"`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val == nil || math.Abs(*val-0.001) > 1e-9 {
t.Fatalf("expected 0.001, got %v", val)
}
})
t.Run("string with spaces ' 10 ' returns 10.0", func(t *testing.T) {
val, err := parseThreshold(json.RawMessage(`" 10 "`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val == nil || *val != 10.0 {
t.Fatalf("expected 10.0, got %v", val)
}
})
t.Run("empty string returns nil", func(t *testing.T) {
val, err := parseThreshold(json.RawMessage(`""`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != nil {
t.Fatalf("expected nil, got %v", *val)
}
})
t.Run("whitespace-only string returns nil", func(t *testing.T) {
val, err := parseThreshold(json.RawMessage(`" "`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != nil {
t.Fatalf("expected nil, got %v", *val)
}
})
t.Run("invalid string returns error", func(t *testing.T) {
_, err := parseThreshold(json.RawMessage(`"abc"`))
if err == nil {
t.Fatal("expected error for non-numeric string")
}
})
t.Run("boolean returns error", func(t *testing.T) {
_, err := parseThreshold(json.RawMessage("true"))
if err == nil {
t.Fatal("expected error for boolean")
}
})
t.Run("array returns error", func(t *testing.T) {
_, err := parseThreshold(json.RawMessage("[1,2]"))
if err == nil {
t.Fatal("expected error for array")
}
})
}
func TestDecodeAlertBody(t *testing.T) {
// Verifies that the struct used in Create handler can decode all
// payload shapes the frontend might send.
type alertBody struct {
Type string `json:"type"`
Threshold json.RawMessage `json:"threshold"`
}
tests := []struct {
name string
payload string
wantErr bool
}{
{
name: "incoming_tx without threshold",
payload: `{"type":"incoming_tx"}`,
},
{
name: "outgoing_tx without threshold",
payload: `{"type":"outgoing_tx"}`,
},
{
name: "large_transfer with number threshold",
payload: `{"type":"large_transfer","threshold":10}`,
},
{
name: "large_transfer with string threshold",
payload: `{"type":"large_transfer","threshold":"10"}`,
},
{
name: "balance_below with number threshold",
payload: `{"type":"balance_below","threshold":0.5}`,
},
{
name: "threshold null",
payload: `{"type":"incoming_tx","threshold":null}`,
},
{
name: "empty object",
payload: `{}`,
},
{
name: "empty body",
payload: ``,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var body alertBody
err := json.Unmarshal([]byte(tt.payload), &body)
if tt.wantErr {
if err == nil {
t.Fatal("expected decode error")
}
return
}
if err != nil {
t.Fatalf("unexpected decode error: %v", err)
}
})
}
}
func TestOldStructFailsWithStringThreshold(t *testing.T) {
// Documents the original bug: *float64 cannot decode a string threshold.
type oldAlertBody struct {
Type string `json:"type"`
Threshold *float64 `json:"threshold"`
}
payload := `{"type":"large_transfer","threshold":"10"}`
var body oldAlertBody
err := json.Unmarshal([]byte(payload), &body)
if err == nil {
t.Fatal("expected error: old struct with *float64 should reject string threshold")
}
}

View File

@@ -55,6 +55,7 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
NotificationEnabled *bool `json:"notification_enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("Failed to decode notification config request body: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return
}