diff --git a/workspace-server/internal/handlers/admin_delegations_test.go b/workspace-server/internal/handlers/admin_delegations_test.go index 8fb2cb3da..ec9dd65b1 100644 --- a/workspace-server/internal/handlers/admin_delegations_test.go +++ b/workspace-server/internal/handlers/admin_delegations_test.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -304,6 +305,122 @@ func TestAdminDelegations_Stats_EmptyTable(t *testing.T) { } } +// ---------- rows.Err() + query-error paths ---------- + +// TestAdminDelegations_List_RowsErr_PartialResults verifies that a mid-scan +// database error sets rows.Err() but List still returns 200 with the rows +// successfully scanned before the error. This is the documented non-fatal +// contract: delegation rows already in `out` are returned; the error is +// logged only. +func TestAdminDelegations_List_RowsErr_PartialResults(t *testing.T) { + mock := setupTestDB(t) + h := NewAdminDelegationsHandler(nil) + + now := time.Now() + // RowError(N, err) only fires when Next() is called for the Nth row. + // With 1 AddRow + RowError(1, err), the 2nd Next() returns io.EOF + // before reaching RowError(1), so the error is silently dropped and + // rows.Err() is nil — making the test a false-positive. The correct + // pattern is 2 AddRows + RowError(1, err): row 0 scans normally, the + // 2nd Next() returns the error, rows.Err() is non-nil, and the + // handler's loop exits with row 0 already in `out`. (Same pattern + // as delegation_list_test.go:198.) + mock.ExpectQuery(`SELECT delegation_id`). + WithArgs("queued", "dispatched", "in_progress", 100). + WillReturnRows(sqlmock.NewRows([]string{ + "delegation_id", "caller_id", "callee_id", "task_preview", + "status", "last_heartbeat", "deadline", "result_preview", "error_detail", + "retry_count", "created_at", "updated_at", + }). + AddRow("deleg-ok", "caller-1", "callee-1", "task ok", + "queued", now, now.Add(2*time.Hour), nil, nil, + 0, now.Add(-1*time.Minute), now.Add(-1*time.Minute)). + AddRow("deleg-bad", "caller-2", "callee-2", "task bad", + "queued", now, now.Add(2*time.Hour), nil, nil, + 0, now, now). + RowError(1, errors.New("storage engine fault"))) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/admin/delegations", nil) + h.List(c) + + // Non-fatal: partial results returned. + if w.Code != http.StatusOK { + t.Fatalf("expected 200 (partial results), got %d: %s", w.Code, w.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("body parse: %v", err) + } + if got := body["count"]; got != float64(1) { + t.Errorf("count: expected 1 (row 0 only), got %v", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +// TestAdminDelegations_Stats_QueryError_Returns500 verifies that a DB query +// failure in Stats returns 500 with a JSON error body. +func TestAdminDelegations_Stats_QueryError_Returns500(t *testing.T) { + mock := setupTestDB(t) + h := NewAdminDelegationsHandler(nil) + + mock.ExpectQuery(`SELECT status, COUNT\(\*\) FROM delegations GROUP BY status`). + WillReturnError(errors.New("connection refused")) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/admin/delegations/stats", nil) + h.Stats(c) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500 on query error, got %d", w.Code) + } +} + +// TestAdminDelegations_Stats_RowsErr_PartialResults verifies that a mid-scan +// database error sets rows.Err() but Stats still returns 200 with the +// counts successfully scanned before the error. The documented non-fatal +// contract: partial stats are returned; the error is logged only. +func TestAdminDelegations_Stats_RowsErr_PartialResults(t *testing.T) { + mock := setupTestDB(t) + h := NewAdminDelegationsHandler(nil) + + // RowError(1, err) only fires when Next() is called for the 2nd row; + // with only 1 AddRow the 2nd Next() returns io.EOF and the error is + // silently dropped. Add a 2nd AddRow so the error actually fires on + // the 2nd row — row 0 scans, rows.Err() is non-nil, handler returns + // partial stats. (Same pattern as delegation_list_test.go:198 and + // the List RowsErr test above.) + mock.ExpectQuery(`SELECT status, COUNT\(\*\) FROM delegations GROUP BY status`). + WillReturnRows(sqlmock.NewRows([]string{"status", "count"}). + AddRow("in_progress", 7). + AddRow("completed", 130). + RowError(1, errors.New("storage engine fault"))) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/admin/delegations/stats", nil) + h.Stats(c) + + // Non-fatal: partial results returned. + if w.Code != http.StatusOK { + t.Fatalf("expected 200 (partial results), got %d: %s", w.Code, w.Body.String()) + } + var stats map[string]int + if err := json.Unmarshal(w.Body.Bytes(), &stats); err != nil { + t.Fatalf("body parse: %v", err) + } + if stats["in_progress"] != 7 { + t.Errorf("in_progress count: expected 7, got %d", stats["in_progress"]) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + // statusFilters is a contract surface — every key here is documented in // the endpoint comment + accepted by the validator. Pin it. func TestStatusFiltersTableShape(t *testing.T) {