mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 11:30:15 +00:00
This commit implements a comprehensive refactoring of the update system to address race conditions, redundant polling, and rate limiting issues. Backend changes: - Add job queue system to ensure only ONE update runs at a time - Implement Server-Sent Events (SSE) for real-time update progress - Add rate limiting to /api/updates/status (5-second minimum per client) - Create SSE broadcaster for push-based status updates - Integrate job queue with update manager for atomic operations - Add comprehensive unit tests for queue and SSE components Frontend changes: - Update UpdateProgressModal to use SSE as primary mechanism - Implement automatic fallback to polling when SSE unavailable - Maintain backward compatibility with existing update flow - Clean up SSE connections on component unmount API changes: - Add new endpoint: GET /api/updates/stream (SSE) - Enhance /api/updates/status with client-based rate limiting - Return cached status with appropriate headers when rate limited Benefits: - Eliminates 429 rate limit errors during updates - Only one update job can run at a time (prevents race conditions) - Real-time updates via SSE reduce unnecessary polling - Graceful degradation to polling when SSE unavailable - Better resource utilization and reduced server load Testing: - All existing tests pass - New unit tests for queue and SSE functionality - Integration tests verify complete update flow
221 lines
5.2 KiB
Go
221 lines
5.2 KiB
Go
package updates
|
|
|
|
import (
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestSSEBroadcaster_AddRemoveClient(t *testing.T) {
|
|
broadcaster := NewSSEBroadcaster()
|
|
defer broadcaster.Close()
|
|
|
|
if broadcaster.GetClientCount() != 0 {
|
|
t.Error("Initial client count should be 0")
|
|
}
|
|
|
|
// Create a mock response writer with Flusher
|
|
w := httptest.NewRecorder()
|
|
|
|
// httptest.ResponseRecorder actually implements http.Flusher in Go 1.21+
|
|
// So AddClient will succeed
|
|
client := broadcaster.AddClient(w, "client-1")
|
|
if client == nil {
|
|
t.Error("AddClient should succeed for ResponseRecorder with Flusher")
|
|
}
|
|
|
|
if broadcaster.GetClientCount() != 1 {
|
|
t.Error("Client count should be 1 after adding client")
|
|
}
|
|
|
|
// Test removal
|
|
broadcaster.RemoveClient("client-1")
|
|
if broadcaster.GetClientCount() != 0 {
|
|
t.Error("Client count should be 0 after removal")
|
|
}
|
|
|
|
// Test removal of non-existent client (should not panic)
|
|
broadcaster.RemoveClient("non-existent")
|
|
}
|
|
|
|
func TestSSEBroadcaster_Broadcast(t *testing.T) {
|
|
broadcaster := NewSSEBroadcaster()
|
|
defer broadcaster.Close()
|
|
|
|
// Broadcast a status
|
|
status := UpdateStatus{
|
|
Status: "downloading",
|
|
Progress: 50,
|
|
Message: "Downloading update...",
|
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
|
}
|
|
|
|
broadcaster.Broadcast(status)
|
|
|
|
// Verify cached status
|
|
cachedStatus, cacheTime := broadcaster.GetCachedStatus()
|
|
if cachedStatus.Status != status.Status {
|
|
t.Errorf("Cached status should be %s, got %s", status.Status, cachedStatus.Status)
|
|
}
|
|
if cachedStatus.Progress != status.Progress {
|
|
t.Errorf("Cached progress should be %d, got %d", status.Progress, cachedStatus.Progress)
|
|
}
|
|
if time.Since(cacheTime) > 1*time.Second {
|
|
t.Error("Cache time should be recent")
|
|
}
|
|
}
|
|
|
|
func TestSSEBroadcaster_GetCachedStatus(t *testing.T) {
|
|
broadcaster := NewSSEBroadcaster()
|
|
defer broadcaster.Close()
|
|
|
|
// Get initial cached status
|
|
status, _ := broadcaster.GetCachedStatus()
|
|
if status.Status != "idle" {
|
|
t.Errorf("Initial status should be idle, got %s", status.Status)
|
|
}
|
|
|
|
// Broadcast new status
|
|
newStatus := UpdateStatus{
|
|
Status: "downloading",
|
|
Progress: 25,
|
|
Message: "Test message",
|
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
|
}
|
|
broadcaster.Broadcast(newStatus)
|
|
|
|
// Verify cached status updated
|
|
cachedStatus, _ := broadcaster.GetCachedStatus()
|
|
if cachedStatus.Status != "downloading" {
|
|
t.Errorf("Cached status should be downloading, got %s", cachedStatus.Status)
|
|
}
|
|
if cachedStatus.Progress != 25 {
|
|
t.Errorf("Cached progress should be 25, got %d", cachedStatus.Progress)
|
|
}
|
|
}
|
|
|
|
// Mock writer that implements http.Flusher
|
|
type mockFlushWriter struct {
|
|
*httptest.ResponseRecorder
|
|
flushed int
|
|
}
|
|
|
|
func (m *mockFlushWriter) Flush() {
|
|
m.flushed++
|
|
}
|
|
|
|
func TestSSEBroadcaster_SendToClient(t *testing.T) {
|
|
broadcaster := NewSSEBroadcaster()
|
|
defer broadcaster.Close()
|
|
|
|
// Create mock writer with Flusher
|
|
mockWriter := &mockFlushWriter{
|
|
ResponseRecorder: httptest.NewRecorder(),
|
|
}
|
|
|
|
client := &SSEClient{
|
|
ID: "test-client",
|
|
Writer: mockWriter,
|
|
Flusher: mockWriter,
|
|
Done: make(chan bool, 1),
|
|
LastActive: time.Now(),
|
|
}
|
|
|
|
status := UpdateStatus{
|
|
Status: "downloading",
|
|
Progress: 50,
|
|
Message: "Test message",
|
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
|
}
|
|
|
|
broadcaster.sendToClient(client, status)
|
|
|
|
// Verify message was written
|
|
body := mockWriter.Body.String()
|
|
if !strings.Contains(body, "data:") {
|
|
t.Error("Message should contain 'data:' prefix")
|
|
}
|
|
if !strings.Contains(body, "downloading") {
|
|
t.Error("Message should contain status")
|
|
}
|
|
if !strings.Contains(body, "Test message") {
|
|
t.Error("Message should contain message")
|
|
}
|
|
|
|
// Verify flushed
|
|
if mockWriter.flushed < 1 {
|
|
t.Error("Should have flushed at least once")
|
|
}
|
|
}
|
|
|
|
func TestSSEBroadcaster_SendHeartbeat(t *testing.T) {
|
|
broadcaster := NewSSEBroadcaster()
|
|
defer broadcaster.Close()
|
|
|
|
mockWriter := &mockFlushWriter{
|
|
ResponseRecorder: httptest.NewRecorder(),
|
|
}
|
|
|
|
client := &SSEClient{
|
|
ID: "test-client",
|
|
Writer: mockWriter,
|
|
Flusher: mockWriter,
|
|
Done: make(chan bool, 1),
|
|
LastActive: time.Now(),
|
|
}
|
|
|
|
// Manually add client to broadcaster
|
|
broadcaster.mu.Lock()
|
|
broadcaster.clients["test-client"] = client
|
|
broadcaster.mu.Unlock()
|
|
|
|
broadcaster.SendHeartbeat()
|
|
|
|
// Give it a moment to send
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
// Verify heartbeat was written
|
|
body := mockWriter.Body.String()
|
|
if !strings.Contains(body, ": heartbeat") {
|
|
t.Errorf("Should contain heartbeat comment, got: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestSSEBroadcaster_Close(t *testing.T) {
|
|
broadcaster := NewSSEBroadcaster()
|
|
|
|
mockWriter := &mockFlushWriter{
|
|
ResponseRecorder: httptest.NewRecorder(),
|
|
}
|
|
|
|
client := &SSEClient{
|
|
ID: "test-client",
|
|
Writer: mockWriter,
|
|
Flusher: mockWriter,
|
|
Done: make(chan bool, 1),
|
|
LastActive: time.Now(),
|
|
}
|
|
|
|
broadcaster.mu.Lock()
|
|
broadcaster.clients["test-client"] = client
|
|
broadcaster.mu.Unlock()
|
|
|
|
if broadcaster.GetClientCount() != 1 {
|
|
t.Error("Should have 1 client")
|
|
}
|
|
|
|
broadcaster.Close()
|
|
|
|
if broadcaster.GetClientCount() != 0 {
|
|
t.Error("Should have 0 clients after close")
|
|
}
|
|
|
|
// Verify client was signaled
|
|
select {
|
|
case <-client.Done:
|
|
// Channel closed as expected
|
|
default:
|
|
t.Error("Client Done channel should be closed")
|
|
}
|
|
}
|