test(handlers): add rows.Err() + query-error coverage for admin_delegations.go #1287

Closed
fullstack-engineer wants to merge 2 commits from fix/handlers-admin-delegations-coverage into staging
@@ -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) {