Files
Pulse/internal/updates/queue_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

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
}