diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94fdbe1..15e0a91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,6 +120,26 @@ jobs: - name: go test (race) run: go test -race ./... + - name: REST handler coverage + run: | + go test ./handlers -coverprofile=handlers.out + go tool cover -func=handlers.out \ + | awk ' + /handlers\/events.go:.*ListEvents/ || /handlers\/events.go:.*GetEvent/ || /handlers\/health.go:.*Health/ { + coverage = $NF + sub(/%$/, "", coverage) + print + if ((coverage + 0) < 80) { + failed = 1 + } + } + END { + if (failed) { + print "REST handler coverage must stay at or above 80% for ListEvents, GetEvent, and Health" + exit 1 + } + }' + # v8 is the first line of the action that supports golangci-lint v2. v4 # invokes it with `--out-format`, which v2 removed, so the lint aborted # with "unknown flag" before checking anything (issue #371). diff --git a/services/api/handlers/health_test.go b/services/api/handlers/health_test.go new file mode 100644 index 0000000..ffef4a1 --- /dev/null +++ b/services/api/handlers/health_test.go @@ -0,0 +1,189 @@ +package handlers_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Depo-dev/trident/services/api/gen" + "github.com/Depo-dev/trident/services/api/handlers" + "github.com/jackc/pgx/v5" + "github.com/redis/go-redis/v9" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type fakeHealthDB struct { + pingErr error + lastLedger *int64 + rowErr error +} + +func (db fakeHealthDB) Ping(context.Context) error { + return db.pingErr +} + +func (db fakeHealthDB) QueryRow(context.Context, string, ...any) pgx.Row { + return fakeHealthRow{lastLedger: db.lastLedger, err: db.rowErr} +} + +func (db fakeHealthDB) Query(context.Context, string, ...any) (pgx.Rows, error) { + return nil, nil +} + +type fakeHealthRow struct { + lastLedger *int64 + err error +} + +func (r fakeHealthRow) Scan(dest ...any) error { + if r.err != nil { + return r.err + } + if len(dest) == 0 || r.lastLedger == nil { + return nil + } + target, ok := dest[0].(**int64) + if !ok { + return nil + } + value := *r.lastLedger + *target = &value + return nil +} + +type fakeRedisPinger struct { + err error +} + +func (p fakeRedisPinger) Ping(ctx context.Context) *redis.StatusCmd { + cmd := redis.NewStatusCmd(ctx) + if p.err != nil { + cmd.SetErr(p.err) + return cmd + } + cmd.SetVal("PONG") + return cmd +} + +type fakeHealthEventsClient struct { + err error +} + +func (c fakeHealthEventsClient) ListEvents(ctx context.Context, in *gen.ListEventsRequest, opts ...grpc.CallOption) (*gen.ListEventsResponse, error) { + if c.err != nil { + return nil, c.err + } + return &gen.ListEventsResponse{}, nil +} + +func TestHealthHandler_TableDriven(t *testing.T) { + dbErr := errors.New("database unavailable") + redisErr := errors.New("redis unavailable") + grpcErr := status.Error(codes.Unavailable, "grpc unavailable") + + tests := []struct { + name string + db handlers.DBPool + redis handlers.RedisPinger + grpc handlers.EventsLister + wantStatus int + wantBody handlers.HealthResponse + check func(t *testing.T, body handlers.HealthResponse) + }{ + { + name: "all dependencies reachable", + db: fakeHealthDB{}, + redis: fakeRedisPinger{}, + grpc: fakeHealthEventsClient{}, + wantStatus: http.StatusOK, + wantBody: handlers.HealthResponse{ + Status: "ok", + Checks: handlers.HealthChecks{ + Postgres: "ok", + Redis: "ok", + GRPCAPI: "ok", + }, + }, + }, + { + name: "db unreachable returns degraded 503", + db: fakeHealthDB{pingErr: dbErr}, + redis: fakeRedisPinger{}, + grpc: fakeHealthEventsClient{}, + wantStatus: http.StatusServiceUnavailable, + check: func(t *testing.T, body handlers.HealthResponse) { + if body.Status != "degraded" { + t.Fatalf("status: got %q, want degraded", body.Status) + } + if !strings.Contains(body.Checks.Postgres, dbErr.Error()) { + t.Fatalf("postgres check: got %q, want db error", body.Checks.Postgres) + } + }, + }, + { + name: "grpc unreachable reflected in checks", + db: fakeHealthDB{}, + redis: fakeRedisPinger{}, + grpc: fakeHealthEventsClient{err: grpcErr}, + wantStatus: http.StatusServiceUnavailable, + check: func(t *testing.T, body handlers.HealthResponse) { + if body.Status != "degraded" { + t.Fatalf("status: got %q, want degraded", body.Status) + } + if !strings.Contains(body.Checks.GRPCAPI, "Unavailable") { + t.Fatalf("grpc_api check: got %q, want Unavailable", body.Checks.GRPCAPI) + } + }, + }, + { + name: "redis unreachable reflected in checks", + db: fakeHealthDB{}, + redis: fakeRedisPinger{err: redisErr}, + grpc: fakeHealthEventsClient{}, + wantStatus: http.StatusServiceUnavailable, + check: func(t *testing.T, body handlers.HealthResponse) { + if body.Status != "degraded" { + t.Fatalf("status: got %q, want degraded", body.Status) + } + if !strings.Contains(body.Checks.Redis, redisErr.Error()) { + t.Fatalf("redis check: got %q, want redis error", body.Checks.Redis) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v1/health", nil) + rr := httptest.NewRecorder() + + handlers.Health(tt.db, tt.redis, tt.grpc)(rr, req) + + if rr.Code != tt.wantStatus { + t.Fatalf("status: got %d, want %d; body: %s", rr.Code, tt.wantStatus, rr.Body.String()) + } + + var body handlers.HealthResponse + if err := json.NewDecoder(rr.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if tt.wantBody.Status != "" { + if body.Status != tt.wantBody.Status { + t.Fatalf("body.status: got %q, want %q", body.Status, tt.wantBody.Status) + } + if body.Checks != tt.wantBody.Checks { + t.Fatalf("checks: got %+v, want %+v", body.Checks, tt.wantBody.Checks) + } + } + if tt.check != nil { + tt.check(t, body) + } + }) + } +} diff --git a/services/api/handlers/rest_handlers_test.go b/services/api/handlers/rest_handlers_test.go new file mode 100644 index 0000000..52b428c --- /dev/null +++ b/services/api/handlers/rest_handlers_test.go @@ -0,0 +1,303 @@ +package handlers_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Depo-dev/trident/services/api/cursor" + "github.com/Depo-dev/trident/services/api/gen" + "github.com/Depo-dev/trident/services/api/handlers" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + restContractID = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4" + restEventID = "550e8400-e29b-41d4-a716-446655440000" +) + +func TestRESTListEventsHandler_TableDriven(t *testing.T) { + tests := []struct { + name string + target string + client *MockEventsClient + wantStatus int + check func(t *testing.T, rr *httptest.ResponseRecorder) + }{ + { + name: "valid request with contract filter forwards to grpc", + target: "/v1/events?contractId=" + restContractID, + client: &MockEventsClient{ + ListEventsFunc: func(ctx context.Context, req *gen.ListEventsRequest) (*gen.ListEventsResponse, error) { + if req.ContractId != restContractID { + t.Fatalf("contract_id forwarded to grpc: got %q, want %q", req.ContractId, restContractID) + } + return &gen.ListEventsResponse{ + Events: []*gen.Event{restEvent(restEventID)}, + }, nil + }, + }, + wantStatus: http.StatusOK, + check: func(t *testing.T, rr *httptest.ResponseRecorder) { + var body handlers.ListEventsResponse + decodeJSON(t, rr, &body) + if len(body.Events) != 1 { + t.Fatalf("events length: got %d, want 1", len(body.Events)) + } + if body.Events[0].ID != restEventID { + t.Fatalf("event id: got %q, want %q", body.Events[0].ID, restEventID) + } + }, + }, + { + name: "request without filters returns empty page", + target: "/v1/events", + client: &MockEventsClient{ + ListEventsFunc: func(ctx context.Context, req *gen.ListEventsRequest) (*gen.ListEventsResponse, error) { + if req.ContractId != "" || req.LedgerFrom != 0 || req.LedgerTo != 0 { + t.Fatalf("unexpected filters forwarded: %+v", req) + } + return &gen.ListEventsResponse{Events: []*gen.Event{}}, nil + }, + }, + wantStatus: http.StatusOK, + check: func(t *testing.T, rr *httptest.ResponseRecorder) { + var body handlers.ListEventsResponse + decodeJSON(t, rr, &body) + if len(body.Events) != 0 { + t.Fatalf("events length: got %d, want 0", len(body.Events)) + } + }, + }, + { + name: "invalid ledgerFrom non integer returns structured 400", + target: "/v1/events?ledgerFrom=abc", + client: &MockEventsClient{}, + wantStatus: http.StatusBadRequest, + check: expectErrorCode("INVALID_ARGUMENT", "ledgerFrom"), + }, + { + name: "invalid ledgerTo negative returns structured 400", + target: "/v1/events?ledgerTo=-1", + client: &MockEventsClient{}, + wantStatus: http.StatusBadRequest, + check: expectErrorCode("INVALID_ARGUMENT", "ledgerTo"), + }, + { + name: "invalid ledger range returns structured 400", + target: "/v1/events?ledgerFrom=20&ledgerTo=10", + client: &MockEventsClient{}, + wantStatus: http.StatusBadRequest, + check: expectErrorCode("INVALID_ARGUMENT", "ledgerTo"), + }, + { + name: "legacy from_ledger query name is rejected", + target: "/v1/events?from_ledger=1", + client: &MockEventsClient{}, + wantStatus: http.StatusBadRequest, + check: expectErrorCode("INVALID_ARGUMENT", "from_ledger"), + }, + { + name: "grpc unavailable returns structured 503", + target: "/v1/events", + client: &MockEventsClient{ + ListEventsFunc: func(ctx context.Context, req *gen.ListEventsRequest) (*gen.ListEventsResponse, error) { + return nil, status.Error(codes.Unavailable, "connection refused") + }, + }, + wantStatus: http.StatusServiceUnavailable, + check: expectErrorCode("UNAVAILABLE", "failed to fetch events"), + }, + { + name: "grpc deadline exceeded returns structured 504", + target: "/v1/events", + client: &MockEventsClient{ + ListEventsFunc: func(ctx context.Context, req *gen.ListEventsRequest) (*gen.ListEventsResponse, error) { + return nil, status.Error(codes.DeadlineExceeded, "deadline exceeded") + }, + }, + wantStatus: http.StatusGatewayTimeout, + check: expectErrorCode("UNAVAILABLE", "failed to fetch events"), + }, + { + name: "pagination cursor is decoded and response cursor is encoded", + target: "/v1/events?cursor=" + cursor.Encode("ledger:42"), + client: &MockEventsClient{ + ListEventsFunc: func(ctx context.Context, req *gen.ListEventsRequest) (*gen.ListEventsResponse, error) { + if req.Cursor != "ledger:42" { + t.Fatalf("cursor forwarded to grpc: got %q, want ledger:42", req.Cursor) + } + return &gen.ListEventsResponse{ + Events: []*gen.Event{}, + HasMore: true, + NextCursor: "ledger:84", + }, nil + }, + }, + wantStatus: http.StatusOK, + check: func(t *testing.T, rr *httptest.ResponseRecorder) { + var body handlers.ListEventsResponse + decodeJSON(t, rr, &body) + if body.NextCursor == nil { + t.Fatal("next_cursor: got nil, want encoded cursor") + } + decoded, err := cursor.Decode(*body.NextCursor) + if err != nil { + t.Fatalf("decode next_cursor: %v", err) + } + if decoded != "ledger:84" { + t.Fatalf("next_cursor decoded: got %q, want ledger:84", decoded) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handlers.SetEventsClient(tt.client) + + req := httptest.NewRequest(http.MethodGet, tt.target, nil) + rr := httptest.NewRecorder() + handlers.ListEvents(rr, req) + + if rr.Code != tt.wantStatus { + t.Fatalf("status: got %d, want %d; body: %s", rr.Code, tt.wantStatus, rr.Body.String()) + } + if tt.check != nil { + tt.check(t, rr) + } + }) + } +} + +func TestRESTGetEventHandler_TableDriven(t *testing.T) { + tests := []struct { + name string + eventID string + client *MockEventsClient + wantStatus int + check func(t *testing.T, rr *httptest.ResponseRecorder) + }{ + { + name: "valid uuid exists", + eventID: restEventID, + client: &MockEventsClient{ + GetEventFunc: func(ctx context.Context, req *gen.GetEventRequest) (*gen.Event, error) { + if req.Id != restEventID { + t.Fatalf("event id forwarded to grpc: got %q, want %q", req.Id, restEventID) + } + return restEvent(restEventID), nil + }, + }, + wantStatus: http.StatusOK, + check: func(t *testing.T, rr *httptest.ResponseRecorder) { + var body struct { + Event *handlers.EventJSON `json:"event"` + } + decodeJSON(t, rr, &body) + if body.Event == nil { + t.Fatal("event: got nil") + } + if body.Event.ID != restEventID { + t.Fatalf("event id: got %q, want %q", body.Event.ID, restEventID) + } + }, + }, + { + name: "valid uuid not found", + eventID: restEventID, + client: &MockEventsClient{ + GetEventFunc: func(ctx context.Context, req *gen.GetEventRequest) (*gen.Event, error) { + return nil, status.Error(codes.NotFound, "event not found") + }, + }, + wantStatus: http.StatusNotFound, + check: expectErrorCode("NOT_FOUND", "event not found"), + }, + { + name: "malformed uuid", + eventID: "not-a-uuid", + client: &MockEventsClient{}, + wantStatus: http.StatusBadRequest, + check: expectErrorCode("INVALID_ARGUMENT", "id"), + }, + { + name: "grpc unavailable", + eventID: restEventID, + client: &MockEventsClient{ + GetEventFunc: func(ctx context.Context, req *gen.GetEventRequest) (*gen.Event, error) { + return nil, status.Error(codes.Unavailable, "connection refused") + }, + }, + wantStatus: http.StatusServiceUnavailable, + check: expectErrorCode("UNAVAILABLE", "failed to fetch event"), + }, + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /v1/events/{id}", handlers.GetEvent) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handlers.SetEventsClient(tt.client) + + req := httptest.NewRequest(http.MethodGet, "/v1/events/"+tt.eventID, nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != tt.wantStatus { + t.Fatalf("status: got %d, want %d; body: %s", rr.Code, tt.wantStatus, rr.Body.String()) + } + if tt.check != nil { + tt.check(t, rr) + } + }) + } +} + +func restEvent(id string) *gen.Event { + return &gen.Event{ + Id: id, + ContractId: restContractID, + LedgerSequence: 100, + LedgerTimestamp: "2026-07-30T08:00:00Z", + TransactionHash: "d8b04e8d7c0f4c93a2b6a7a8d8f930beefcafe1234567890abcdef1234567890", + EventIndex: 1, + EventType: "contract", + Topics: []string{"transfer"}, + Data: `{"amount":"100"}`, + CreatedAt: "2026-07-30T08:00:01Z", + } +} + +func expectErrorCode(code, messageContains string) func(t *testing.T, rr *httptest.ResponseRecorder) { + return func(t *testing.T, rr *httptest.ResponseRecorder) { + var body struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + decodeJSON(t, rr, &body) + if body.Error.Code != code { + t.Fatalf("error.code: got %q, want %q; body: %s", body.Error.Code, code, rr.Body.String()) + } + if messageContains != "" && !strings.Contains(body.Error.Message, messageContains) { + t.Fatalf("error.message: got %q, want containing %q", body.Error.Message, messageContains) + } + } +} + +func decodeJSON(t *testing.T, rr *httptest.ResponseRecorder, dest any) { + t.Helper() + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("content-type: got %q, want application/json", ct) + } + if err := json.NewDecoder(rr.Body).Decode(dest); err != nil { + t.Fatalf("decode json: %v; body: %s", err, rr.Body.String()) + } +}