Nereus/apps/api/http_test.go

256 lines
9.6 KiB
Go

package main
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/prometheus/client_golang/prometheus"
)
type fakeStore struct{ pingErr error }
const (
testBuoyID = "00000000-0000-4000-8000-000000000001"
missingBuoyID = "00000000-0000-4000-8000-000000000002"
)
func (f *fakeStore) Ping(context.Context) error { return f.pingErr }
func (f *fakeStore) Migrate(context.Context) error { return nil }
func (f *fakeStore) Close() {}
func (f *fakeStore) ListBuoys(context.Context) ([]buoy, error) {
return []buoy{{ID: testBuoyID, Name: "Atlantic"}}, nil
}
func (f *fakeStore) CreateBuoy(_ context.Context, b buoy) (buoy, error) {
b.CreatedAt = time.Unix(1, 0)
return b, nil
}
func (f *fakeStore) GetBuoy(_ context.Context, id string) (buoy, error) {
if id == missingBuoyID {
return buoy{}, pgx.ErrNoRows
}
return buoy{ID: id}, nil
}
func (f *fakeStore) DeleteBuoy(_ context.Context, id string) error {
if id == missingBuoyID {
return pgx.ErrNoRows
}
return nil
}
func (f *fakeStore) ListReadings(context.Context, string, *time.Time, *time.Time, int, int) ([]reading, error) {
return []reading{}, nil
}
func (f *fakeStore) CreateReading(_ context.Context, r reading) (reading, error) {
r.RecordedAt = time.Unix(1, 0)
return r, nil
}
func (f *fakeStore) AggregateReadings(context.Context, time.Duration) ([]aggregate, error) {
return []aggregate{}, nil
}
type failingStore struct {
fakeStore
operation string
}
var errDatabaseUnavailable = errors.New("database unavailable")
func (f *failingStore) ListBuoys(context.Context) ([]buoy, error) {
if f.operation == "list buoys" {
return nil, errDatabaseUnavailable
}
return f.fakeStore.ListBuoys(context.Background())
}
func (f *failingStore) CreateBuoy(ctx context.Context, b buoy) (buoy, error) {
if f.operation == "create buoy" {
return buoy{}, errDatabaseUnavailable
}
return f.fakeStore.CreateBuoy(ctx, b)
}
func (f *failingStore) GetBuoy(ctx context.Context, id string) (buoy, error) {
if f.operation == "get buoy" {
return buoy{}, errDatabaseUnavailable
}
return f.fakeStore.GetBuoy(ctx, id)
}
func (f *failingStore) DeleteBuoy(ctx context.Context, id string) error {
if f.operation == "delete buoy" {
return errDatabaseUnavailable
}
return f.fakeStore.DeleteBuoy(ctx, id)
}
func (f *failingStore) ListReadings(ctx context.Context, buoyID string, from, to *time.Time, limit, offset int) ([]reading, error) {
if f.operation == "list readings" {
return nil, errDatabaseUnavailable
}
return f.fakeStore.ListReadings(ctx, buoyID, from, to, limit, offset)
}
func (f *failingStore) CreateReading(ctx context.Context, r reading) (reading, error) {
if f.operation == "create reading" {
return reading{}, errDatabaseUnavailable
}
return f.fakeStore.CreateReading(ctx, r)
}
func (f *failingStore) AggregateReadings(ctx context.Context, window time.Duration) ([]aggregate, error) {
if f.operation == "aggregate readings" {
return nil, errDatabaseUnavailable
}
return f.fakeStore.AggregateReadings(ctx, window)
}
func testAPI(s store, chaos float64) http.Handler {
handler, _ := testAPIWithMetrics(s, chaos)
return handler
}
func testAPIWithMetrics(s store, chaos float64) (http.Handler, *metrics) {
reg := prometheus.NewRegistry()
ready := &atomic.Bool{}
ready.Store(true)
m := newMetrics(reg)
a := &api{store: s, metrics: m, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), version: "test", chaosRate: chaos, migrated: ready, prometheus: reg}
return a.routes(), m
}
func TestHandlers(t *testing.T) {
tests := []struct {
name, method, path, body string
want int
}{
{"health", http.MethodGet, "/healthz", "", http.StatusOK},
{"ready", http.MethodGet, "/readyz", "", http.StatusOK},
{"metrics", http.MethodGet, "/metrics", "", http.StatusOK},
{"list buoys", http.MethodGet, "/api/v1/buoys", "", http.StatusOK},
{"create buoy", http.MethodPost, "/api/v1/buoys", `{"name":"North","latitude":42,"longitude":-8}`, http.StatusCreated},
{"invalid buoy", http.MethodPost, "/api/v1/buoys", `{"name":"","latitude":42,"longitude":-8}`, http.StatusBadRequest},
{"get buoy", http.MethodGet, "/api/v1/buoys/" + testBuoyID, "", http.StatusOK},
{"missing buoy", http.MethodGet, "/api/v1/buoys/" + missingBuoyID, "", http.StatusNotFound},
{"invalid buoy ID", http.MethodGet, "/api/v1/buoys/not-a-uuid", "", http.StatusBadRequest},
{"delete buoy", http.MethodDelete, "/api/v1/buoys/" + testBuoyID, "", http.StatusNoContent},
{"list readings", http.MethodGet, "/api/v1/readings?limit=10", "", http.StatusOK},
{"bad timestamp", http.MethodGet, "/api/v1/readings?from=yesterday", "", http.StatusBadRequest},
{"create reading", http.MethodPost, "/api/v1/readings", `{"buoy_id":"` + testBuoyID + `","water_temp":14,"wave_height":2}`, http.StatusCreated},
{"aggregate", http.MethodGet, "/api/v1/readings/aggregate?window=1h", "", http.StatusOK},
{"bad aggregate", http.MethodGet, "/api/v1/readings/aggregate?window=bad", "", http.StatusBadRequest},
}
handler := testAPI(&fakeStore{}, 0)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
if recorder.Code != tt.want {
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, tt.want, recorder.Body.String())
}
if got := recorder.Header().Get("X-Nereus-Version"); got != "test" {
t.Errorf("X-Nereus-Version = %q, want test", got)
}
})
}
}
func TestReadinessFailure(t *testing.T) {
recorder := httptest.NewRecorder()
testAPI(&fakeStore{pingErr: context.DeadlineExceeded}, 0).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable)
}
}
func TestChaosMiddleware(t *testing.T) {
recorder := httptest.NewRecorder()
testAPI(&fakeStore{}, 1).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/buoys", nil))
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusInternalServerError)
}
recorder = httptest.NewRecorder()
testAPI(&fakeStore{}, 1).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("health status = %d, want %d", recorder.Code, http.StatusOK)
}
}
func TestDataEndpointsReturnInternalErrorOnDatabaseFailure(t *testing.T) {
tests := []struct {
name, method, path, body string
}{
{"list buoys", http.MethodGet, "/api/v1/buoys", ""},
{"create buoy", http.MethodPost, "/api/v1/buoys", `{"name":"North","latitude":42,"longitude":-8}`},
{"get buoy", http.MethodGet, "/api/v1/buoys/" + testBuoyID, ""},
{"delete buoy", http.MethodDelete, "/api/v1/buoys/" + testBuoyID, ""},
{"list readings", http.MethodGet, "/api/v1/readings", ""},
{"create reading", http.MethodPost, "/api/v1/readings", `{"buoy_id":"` + testBuoyID + `","water_temp":14,"wave_height":2}`},
{"aggregate readings", http.MethodGet, "/api/v1/readings/aggregate?window=1h", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
testAPI(&failingStore{operation: tt.name}, 0).ServeHTTP(recorder, httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body)))
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusInternalServerError, recorder.Body.String())
}
if recorder.Body.String() != "{\"error\":\"internal error\"}\n" {
t.Fatalf("body = %q", recorder.Body.String())
}
})
}
}
func TestJSONBodyValidation(t *testing.T) {
tests := []struct {
name, body string
}{
{"malformed", `{"name":`},
{"multiple values", `{"name":"North","latitude":42,"longitude":-8} {}`},
{"oversized", `{"name":"` + strings.Repeat("x", 1<<20) + `","latitude":42,"longitude":-8}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
testAPI(&fakeStore{}, 0).ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/buoys", strings.NewReader(tt.body)))
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadRequest)
}
})
}
}
func TestMetricNamesAndRouteTemplateLabels(t *testing.T) {
handler, metrics := testAPIWithMetrics(&fakeStore{}, 0)
metrics.db.WithLabelValues("test").Observe(0)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/buoys/"+testBuoyID, nil))
recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/metrics", nil))
body := recorder.Body.String()
for _, name := range []string{
"nereus_http_requests_total",
"nereus_http_request_duration_seconds",
"nereus_db_query_duration_seconds",
"nereus_readings_ingested_total",
"nereus_buoys_active",
} {
if !strings.Contains(body, "# HELP "+name+" ") {
t.Errorf("metrics output does not contain %q", name)
}
}
if !strings.Contains(body, `nereus_http_requests_total{method="GET",path="/api/v1/buoys/{id}",status="200",version="test"} 1`) {
t.Error("request counter does not contain the exact labels and buoy route template")
}
if !strings.Contains(body, `nereus_http_request_duration_seconds_count{method="GET",path="/api/v1/buoys/{id}",version="test"} 1`) {
t.Error("request histogram does not contain the exact labels and buoy route template")
}
if strings.Contains(body, `path="/api/v1/buoys/`+testBuoyID+`"`) {
t.Error("metrics output contains a resolved resource path")
}
}