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") } }