mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01: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
205 lines
5.0 KiB
Go
205 lines
5.0 KiB
Go
package updates
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestUpdateQueue_Enqueue(t *testing.T) {
|
|
queue := NewUpdateQueue()
|
|
|
|
// Test successful enqueue
|
|
job1, accepted := queue.Enqueue("https://example.com/update1.tar.gz")
|
|
if !accepted {
|
|
t.Fatal("First job should be accepted")
|
|
}
|
|
if job1 == nil {
|
|
t.Fatal("Job should not be nil")
|
|
}
|
|
if job1.State != JobStateQueued {
|
|
t.Errorf("Job state should be queued, got %s", job1.State)
|
|
}
|
|
|
|
// Test rejection when another job is running
|
|
queue.MarkRunning(job1.ID)
|
|
job2, accepted := queue.Enqueue("https://example.com/update2.tar.gz")
|
|
if accepted {
|
|
t.Fatal("Second job should be rejected when first is running")
|
|
}
|
|
if job2 != nil {
|
|
t.Error("Rejected job should be nil")
|
|
}
|
|
}
|
|
|
|
func TestUpdateQueue_MarkRunning(t *testing.T) {
|
|
queue := NewUpdateQueue()
|
|
|
|
job, _ := queue.Enqueue("https://example.com/update.tar.gz")
|
|
if job.State != JobStateQueued {
|
|
t.Errorf("Initial state should be queued, got %s", job.State)
|
|
}
|
|
|
|
success := queue.MarkRunning(job.ID)
|
|
if !success {
|
|
t.Fatal("MarkRunning should succeed")
|
|
}
|
|
|
|
currentJob := queue.GetCurrentJob()
|
|
if currentJob.State != JobStateRunning {
|
|
t.Errorf("State should be running, got %s", currentJob.State)
|
|
}
|
|
|
|
// Test marking wrong job ID
|
|
success = queue.MarkRunning("wrong-id")
|
|
if success {
|
|
t.Error("MarkRunning with wrong ID should fail")
|
|
}
|
|
}
|
|
|
|
func TestUpdateQueue_MarkCompleted(t *testing.T) {
|
|
queue := NewUpdateQueue()
|
|
|
|
job, _ := queue.Enqueue("https://example.com/update.tar.gz")
|
|
queue.MarkRunning(job.ID)
|
|
|
|
// Test successful completion
|
|
queue.MarkCompleted(job.ID, nil)
|
|
currentJob := queue.GetCurrentJob()
|
|
if currentJob.State != JobStateCompleted {
|
|
t.Errorf("State should be completed, got %s", currentJob.State)
|
|
}
|
|
if currentJob.Error != nil {
|
|
t.Error("Error should be nil for successful completion")
|
|
}
|
|
|
|
// Check history
|
|
history := queue.GetHistory()
|
|
if len(history) != 1 {
|
|
t.Errorf("History should contain 1 job, got %d", len(history))
|
|
}
|
|
}
|
|
|
|
func TestUpdateQueue_MarkCompletedWithError(t *testing.T) {
|
|
queue := NewUpdateQueue()
|
|
|
|
job, _ := queue.Enqueue("https://example.com/update.tar.gz")
|
|
queue.MarkRunning(job.ID)
|
|
|
|
// Test failed completion
|
|
testErr := &testError{"test error"}
|
|
queue.MarkCompleted(job.ID, testErr)
|
|
|
|
currentJob := queue.GetCurrentJob()
|
|
if currentJob.State != JobStateFailed {
|
|
t.Errorf("State should be failed, got %s", currentJob.State)
|
|
}
|
|
if currentJob.Error == nil {
|
|
t.Error("Error should not be nil for failed completion")
|
|
}
|
|
if currentJob.Error.Error() != "test error" {
|
|
t.Errorf("Error message should be 'test error', got %s", currentJob.Error.Error())
|
|
}
|
|
}
|
|
|
|
func TestUpdateQueue_Cancel(t *testing.T) {
|
|
queue := NewUpdateQueue()
|
|
|
|
job, _ := queue.Enqueue("https://example.com/update.tar.gz")
|
|
queue.MarkRunning(job.ID)
|
|
|
|
success := queue.Cancel(job.ID)
|
|
if !success {
|
|
t.Fatal("Cancel should succeed")
|
|
}
|
|
|
|
currentJob := queue.GetCurrentJob()
|
|
if currentJob.State != JobStateCancelled {
|
|
t.Errorf("State should be cancelled, got %s", currentJob.State)
|
|
}
|
|
|
|
// Verify context was cancelled
|
|
select {
|
|
case <-currentJob.Context.Done():
|
|
// Context was cancelled as expected
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Error("Context should be cancelled")
|
|
}
|
|
}
|
|
|
|
func TestUpdateQueue_IsRunning(t *testing.T) {
|
|
queue := NewUpdateQueue()
|
|
|
|
if queue.IsRunning() {
|
|
t.Error("Queue should not be running initially")
|
|
}
|
|
|
|
job, _ := queue.Enqueue("https://example.com/update.tar.gz")
|
|
if !queue.IsRunning() {
|
|
t.Error("Queue should be running after enqueue")
|
|
}
|
|
|
|
queue.MarkRunning(job.ID)
|
|
if !queue.IsRunning() {
|
|
t.Error("Queue should be running after marking as running")
|
|
}
|
|
|
|
queue.MarkCompleted(job.ID, nil)
|
|
// Note: IsRunning will still return true for a short period after completion
|
|
// This is by design to allow status polling
|
|
}
|
|
|
|
func TestUpdateQueue_History(t *testing.T) {
|
|
queue := NewUpdateQueue()
|
|
|
|
// Add multiple jobs
|
|
for i := 0; i < 5; i++ {
|
|
job, _ := queue.Enqueue("https://example.com/update.tar.gz")
|
|
queue.MarkRunning(job.ID)
|
|
queue.MarkCompleted(job.ID, nil)
|
|
|
|
// Wait for the job to be cleared from current
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
|
|
history := queue.GetHistory()
|
|
if len(history) != 5 {
|
|
t.Errorf("History should contain 5 jobs, got %d", len(history))
|
|
}
|
|
|
|
// Verify history ordering (should be chronological)
|
|
for i := 1; i < len(history); i++ {
|
|
if history[i].StartedAt.Before(history[i-1].StartedAt) {
|
|
t.Error("History should be in chronological order")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUpdateQueue_MaxHistory(t *testing.T) {
|
|
queue := NewUpdateQueue()
|
|
queue.maxHistory = 3
|
|
|
|
// Add more jobs than maxHistory
|
|
for i := 0; i < 5; i++ {
|
|
job, _ := queue.Enqueue("https://example.com/update.tar.gz")
|
|
queue.MarkRunning(job.ID)
|
|
queue.MarkCompleted(job.ID, nil)
|
|
|
|
// Wait for the job to be cleared
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
|
|
history := queue.GetHistory()
|
|
if len(history) > queue.maxHistory {
|
|
t.Errorf("History should be limited to %d jobs, got %d", queue.maxHistory, len(history))
|
|
}
|
|
}
|
|
|
|
// Helper type for testing
|
|
type testError struct {
|
|
msg string
|
|
}
|
|
|
|
func (e *testError) Error() string {
|
|
return e.msg
|
|
}
|