256 lines
9.6 KiB
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")
|
|
}
|
|
}
|