Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 49 additions & 7 deletions backend/controllers/get_tasks.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
package controllers

import (
"ccsync_backend/models"
"ccsync_backend/utils"
"ccsync_backend/utils/tw"
"encoding/json"
"fmt"
"net/http"
"os"
)

// fetchTasksFromTaskwarrior is the Taskwarrior fetch used by TasksHandler.
// Tests override it to avoid requiring a live Taskwarrior installation.
var fetchTasksFromTaskwarrior = tw.FetchTasksFromTaskwarrior

// TasksHandler godoc
// @Summary Get all tasks
// @Description Fetch all tasks from Taskwarrior for a specific user via Headers
Expand Down Expand Up @@ -34,15 +41,50 @@ func TasksHandler(w http.ResponseWriter, r *http.Request) {
}

if r.Method == http.MethodGet {
tasks, err := tw.FetchTasksFromTaskwarrior(email, encryptionSecret, origin, UUID)
if err != nil || tasks == nil {
http.Error(w, "Failed to fetch tasks at backend", http.StatusInternalServerError)
return
type fetchResult struct {
tasks []models.Task
err error
}
// Buffer of 1 so the queue worker never blocks if the client disconnects
// before the handler receives the result.
resultCh := make(chan fetchResult, 1)

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tasks)
return
job := Job{
Name: "Get Tasks",
Execute: func() (err error) {
var tasks []models.Task
defer func() {
if rec := recover(); rec != nil {
utils.Logger.Errorf("Get Tasks job panicked: %v", rec)
err = fmt.Errorf("panic while fetching tasks: %v", rec)
}
resultCh <- fetchResult{tasks: tasks, err: err}
}()
tasks, err = fetchTasksFromTaskwarrior(email, encryptionSecret, origin, UUID)
return err
},
}
GlobalJobQueue.AddJob(job)

select {
case res := <-resultCh:
if res.err != nil {
utils.Logger.Errorf("Failed to fetch tasks: %v", res.err)
http.Error(w, "Failed to fetch tasks at backend", http.StatusInternalServerError)
return
}
tasks := res.tasks
if tasks == nil {
tasks = []models.Task{}
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tasks)
return
case <-r.Context().Done():
// Client disconnected; the job may still finish and send into resultCh.
return
}
}

http.Error(w, "Invalid request method", http.StatusMethodNotAllowed)
Expand Down
226 changes: 226 additions & 0 deletions backend/controllers/get_tasks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
package controllers

import (
"ccsync_backend/models"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func withMockFetch(t *testing.T, fn func(email, encryptionSecret, origin, UUID string) ([]models.Task, error)) {
t.Helper()
orig := fetchTasksFromTaskwarrior
fetchTasksFromTaskwarrior = fn
t.Cleanup(func() {
fetchTasksFromTaskwarrior = orig
})
}

func Test_TasksHandler_MissingHeaders(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
rr := httptest.NewRecorder()
TasksHandler(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}

func Test_TasksHandler_MethodNotAllowed(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/tasks", nil)
req.Header.Set("X-User-Email", "[email protected]")
req.Header.Set("X-Encryption-Secret", "secret")
req.Header.Set("X-User-UUID", "uuid")
rr := httptest.NewRecorder()
TasksHandler(rr, req)
assert.Equal(t, http.StatusMethodNotAllowed, rr.Code)
}

func Test_TasksHandler_ReturnsOwnFetchResult(t *testing.T) {
GlobalJobQueue = NewJobQueue()
withMockFetch(t, func(email, encryptionSecret, origin, UUID string) ([]models.Task, error) {
return []models.Task{{
UUID: UUID,
Description: email,
}}, nil
})

var wg sync.WaitGroup
results := make([][]models.Task, 2)
errs := make([]error, 2)

for i, uuid := range []string{"user-a", "user-b"} {
wg.Add(1)
go func(i int, uuid string) {
defer wg.Done()
req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
req.Header.Set("X-User-Email", uuid+"@example.com")
req.Header.Set("X-Encryption-Secret", "secret")
req.Header.Set("X-User-UUID", uuid)
rr := httptest.NewRecorder()
TasksHandler(rr, req)
if rr.Code != http.StatusOK {
errs[i] = fmt.Errorf("status %d: %s", rr.Code, rr.Body.String())
return
}
var tasks []models.Task
if err := json.Unmarshal(rr.Body.Bytes(), &tasks); err != nil {
errs[i] = err
return
}
results[i] = tasks
}(i, uuid)
}
wg.Wait()

require.NoError(t, errs[0])
require.NoError(t, errs[1])
require.Len(t, results[0], 1)
require.Len(t, results[1], 1)
assert.Equal(t, "user-a", results[0][0].UUID)
assert.Equal(t, "user-b", results[1][0].UUID)
assert.Equal(t, "[email protected]", results[0][0].Description)
assert.Equal(t, "[email protected]", results[1][0].Description)
}

func Test_TasksHandler_EmptyListReturnsJSONArray(t *testing.T) {
GlobalJobQueue = NewJobQueue()
withMockFetch(t, func(email, encryptionSecret, origin, UUID string) ([]models.Task, error) {
return nil, nil
})

req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
req.Header.Set("X-User-Email", "[email protected]")
req.Header.Set("X-Encryption-Secret", "secret")
req.Header.Set("X-User-UUID", "uuid")
rr := httptest.NewRecorder()
TasksHandler(rr, req)

assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))
assert.JSONEq(t, "[]", rr.Body.String())
}

func Test_TasksHandler_FetchFailureReturns500(t *testing.T) {
GlobalJobQueue = NewJobQueue()
withMockFetch(t, func(email, encryptionSecret, origin, UUID string) ([]models.Task, error) {
return nil, errors.New("sync failed")
})

req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
req.Header.Set("X-User-Email", "[email protected]")
req.Header.Set("X-Encryption-Secret", "secret")
req.Header.Set("X-User-UUID", "uuid")
rr := httptest.NewRecorder()
TasksHandler(rr, req)

assert.Equal(t, http.StatusInternalServerError, rr.Code)
assert.Contains(t, rr.Body.String(), "Failed to fetch tasks at backend")
}

func Test_TasksHandler_FetchPanicReturns500AndKeepsQueueUsable(t *testing.T) {
GlobalJobQueue = NewJobQueue()
withMockFetch(t, func(email, encryptionSecret, origin, UUID string) ([]models.Task, error) {
panic("simulated fetch panic")
})

handlerDone := make(chan *httptest.ResponseRecorder, 1)
go func() {
req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
req.Header.Set("X-User-Email", "[email protected]")
req.Header.Set("X-Encryption-Secret", "secret")
req.Header.Set("X-User-UUID", "uuid")
rr := httptest.NewRecorder()
TasksHandler(rr, req)
handlerDone <- rr
}()

var rr *httptest.ResponseRecorder
select {
case rr = <-handlerDone:
case <-time.After(2 * time.Second):
t.Fatal("handler did not complete after fetch panic")
}

assert.Equal(t, http.StatusInternalServerError, rr.Code)
assert.Contains(t, rr.Body.String(), "Failed to fetch tasks at backend")

followUpDone := make(chan struct{})
GlobalJobQueue.AddJob(Job{
Name: "follow-up-after-panic",
Execute: func() error {
close(followUpDone)
return nil
},
})

select {
case <-followUpDone:
case <-time.After(2 * time.Second):
t.Fatal("queue worker blocked after fetch panic")
}
}

func Test_TasksHandler_ClientDisconnectDoesNotBlockQueue(t *testing.T) {
GlobalJobQueue = NewJobQueue()

fetchStarted := make(chan struct{})
fetchRelease := make(chan struct{})
withMockFetch(t, func(email, encryptionSecret, origin, UUID string) ([]models.Task, error) {
close(fetchStarted)
<-fetchRelease
return []models.Task{}, nil
})

ctx, cancel := context.WithCancel(context.Background())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/tasks", nil)
require.NoError(t, err)
req.Header.Set("X-User-Email", "[email protected]")
req.Header.Set("X-Encryption-Secret", "secret")
req.Header.Set("X-User-UUID", "uuid")

handlerDone := make(chan struct{})
go func() {
defer close(handlerDone)
rr := httptest.NewRecorder()
TasksHandler(rr, req)
}()

select {
case <-fetchStarted:
case <-time.After(2 * time.Second):
t.Fatal("fetch job did not start")
}

cancel()

select {
case <-handlerDone:
case <-time.After(2 * time.Second):
t.Fatal("handler did not return after client cancel")
}

// A subsequent job must still be able to run (queue worker not stuck).
followUpDone := make(chan struct{})
GlobalJobQueue.AddJob(Job{
Name: "follow-up",
Execute: func() error {
close(followUpDone)
return nil
},
})

close(fetchRelease)

select {
case <-followUpDone:
case <-time.After(2 * time.Second):
t.Fatal("queue worker blocked after client disconnect")
}
}
68 changes: 68 additions & 0 deletions backend/controllers/job_queue_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package controllers

import (
"sync"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func Test_JobQueue_ExecutesSequentially(t *testing.T) {
q := NewJobQueue()

var mu sync.Mutex
var order []int
startedSecond := make(chan struct{})
releaseFirst := make(chan struct{})
done := make(chan struct{}, 2)

q.AddJob(Job{
Name: "first",
Execute: func() error {
mu.Lock()
order = append(order, 1)
mu.Unlock()
close(startedSecond)
<-releaseFirst
done <- struct{}{}
return nil
},
})
q.AddJob(Job{
Name: "second",
Execute: func() error {
mu.Lock()
order = append(order, 2)
mu.Unlock()
done <- struct{}{}
return nil
},
})

select {
case <-startedSecond:
case <-time.After(2 * time.Second):
t.Fatal("first job did not start")
}

// While the first job is held open, the second must not have run yet.
mu.Lock()
require.Equal(t, []int{1}, order)
mu.Unlock()

close(releaseFirst)

for i := 0; i < 2; i++ {
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("jobs did not finish")
}
}

mu.Lock()
defer mu.Unlock()
assert.Equal(t, []int{1, 2}, order)
}
Loading
Loading