Files
Pulse/internal/api/rate_limit_integration_test.go
rcourtman b6e4c20e6b fix: preserve email rateLimit when not explicitly provided in request
Backend fix:
- Added presence check in UpdateEmailConfig to detect when rateLimit is
  omitted from JSON (vs explicitly set to 0)
- Preserves existing rateLimit value when field is not present in request
- Added comprehensive integration tests covering all scenarios

Frontend fix:
- Added rateLimit to EmailConfig interface
- Fixed getEmailConfig to read rateLimit from server response
- Fixed updateEmailConfig to include rateLimit when set
- Fixed two places in Alerts.tsx that hardcoded rateLimit: 60

Additional fixes:
- Added Array.isArray guards in DiagnosticsPanel sanitization
- Initialized Nodes/PBS arrays in diagnostics response to prevent null

Closes rate limit persistence bug where updating email settings would
reset the rate limit to default value.
2026-02-05 09:59:05 +00:00

141 lines
4.5 KiB
Go

package api
import (
"bytes"
"encoding/json"
"net/http/httptest"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// TestRateLimitPersistence_FullRoundTrip tests the complete data flow:
// 1. Existing config has RateLimit=120
// 2. User updates other fields without rateLimit in JSON
// 3. Backend preserves RateLimit=120
// 4. GET returns RateLimit=120
func TestRateLimitPersistence_FullRoundTrip(t *testing.T) {
// Setup mocks
mockMonitor := new(MockNotificationMonitor)
mockManager := new(MockNotificationManager)
mockPersistence := new(MockNotificationConfigPersistence)
mockMonitor.On("GetNotificationManager").Return(mockManager)
mockMonitor.On("GetConfigPersistence").Return(mockPersistence)
h := NewNotificationHandlers(nil, mockMonitor)
// Existing config in "database" has RateLimit=120
existingConfig := notifications.EmailConfig{
Enabled: true,
SMTPHost: "smtp.example.com",
Password: "secret",
RateLimit: 120,
}
// Test 1: GET returns the rateLimit
t.Run("GET_returns_rateLimit", func(t *testing.T) {
mockManager.On("GetEmailConfig").Return(existingConfig).Once()
req := httptest.NewRequest("GET", "/api/notifications/email", nil)
w := httptest.NewRecorder()
h.GetEmailConfig(w, req)
assert.Equal(t, 200, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
// Verify rateLimit is returned (password should be empty for security)
assert.Equal(t, float64(120), resp["rateLimit"])
assert.Equal(t, "", resp["password"]) // Redacted
})
// Test 2: PUT without rateLimit preserves existing value
t.Run("PUT_without_rateLimit_preserves_existing", func(t *testing.T) {
// Return existing config when handler calls GetEmailConfig
mockManager.On("GetEmailConfig").Return(existingConfig).Once()
// Expect SetEmailConfig to be called WITH RateLimit=120 preserved
mockManager.On("SetEmailConfig", mock.MatchedBy(func(c notifications.EmailConfig) bool {
t.Logf("SetEmailConfig called with RateLimit=%d", c.RateLimit)
return c.RateLimit == 120 && c.SMTPHost == "smtp.newhost.com"
})).Return().Once()
mockPersistence.On("SaveEmailConfig", mock.MatchedBy(func(c notifications.EmailConfig) bool {
return c.RateLimit == 120
})).Return(nil).Once()
// Request body does NOT include rateLimit
payload := map[string]interface{}{
"enabled": true,
"server": "smtp.newhost.com",
"password": "newpassword",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/api/notifications/email", bytes.NewReader(body))
w := httptest.NewRecorder()
h.UpdateEmailConfig(w, req)
assert.Equal(t, 200, w.Code)
mockManager.AssertExpectations(t)
mockPersistence.AssertExpectations(t)
})
// Test 3: PUT with rateLimit=0 explicitly sets it to 0
t.Run("PUT_with_explicit_rateLimit_0_sets_to_0", func(t *testing.T) {
mockManager.On("GetEmailConfig").Return(existingConfig).Once()
mockManager.On("SetEmailConfig", mock.MatchedBy(func(c notifications.EmailConfig) bool {
t.Logf("SetEmailConfig called with RateLimit=%d", c.RateLimit)
return c.RateLimit == 0 // User explicitly set to 0
})).Return().Once()
mockPersistence.On("SaveEmailConfig", mock.Anything).Return(nil).Once()
// Request body INCLUDES rateLimit: 0
payload := map[string]interface{}{
"enabled": true,
"server": "smtp.example.com",
"rateLimit": 0,
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/api/notifications/email", bytes.NewReader(body))
w := httptest.NewRecorder()
h.UpdateEmailConfig(w, req)
assert.Equal(t, 200, w.Code)
mockManager.AssertExpectations(t)
})
// Test 4: PUT with new rateLimit updates it
t.Run("PUT_with_new_rateLimit_updates", func(t *testing.T) {
mockManager.On("GetEmailConfig").Return(existingConfig).Once()
mockManager.On("SetEmailConfig", mock.MatchedBy(func(c notifications.EmailConfig) bool {
t.Logf("SetEmailConfig called with RateLimit=%d", c.RateLimit)
return c.RateLimit == 60 // User changed to 60
})).Return().Once()
mockPersistence.On("SaveEmailConfig", mock.Anything).Return(nil).Once()
payload := map[string]interface{}{
"enabled": true,
"server": "smtp.example.com",
"rateLimit": 60,
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/api/notifications/email", bytes.NewReader(body))
w := httptest.NewRecorder()
h.UpdateEmailConfig(w, req)
assert.Equal(t, 200, w.Code)
mockManager.AssertExpectations(t)
})
}