Pulse/internal/updates/sse_test.go
Claude 0af921dc23 Refactor update service to eliminate polling and race conditions
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
2025-11-11 09:33:05 +00:00

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")
}
}