Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/app/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func Build(ctx context.Context, cfg config.Config) (*App, error) {
getOrder := queries.NewGetOrderHandler(database, ordersStore, tracer)
dispatcher := outbox.NewDispatcher(clock, database, outboxStore, publisher, logger, metrics, tracer)
backgroundWorker := worker.New(cfg.WorkerInterval, dispatcher, logger)
health := handlers.NewHealthHandler(func(ctx context.Context) error {
health := handlers.NewHealthHandler(logger, func(ctx context.Context) error {
return database.Ping(ctx)
})
server := httptransport.NewServer(httptransport.Dependencies{
Expand Down
1 change: 1 addition & 0 deletions internal/application/commands/create_order_invalid_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func (invalidOutboxRepo) Add(context.Context, ports.DBTX, ports.OutboxMessage) e
func (invalidOutboxRepo) ListPending(context.Context, ports.DBTX, int) ([]ports.OutboxMessage, error) {
return nil, nil
}

func (invalidOutboxRepo) MarkDispatched(context.Context, ports.DBTX, string, time.Time) error {
return nil
}
Expand Down
1 change: 1 addition & 0 deletions internal/infrastructure/outbox/dispatcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func (o *dispatcherOutbox) Add(context.Context, ports.DBTX, ports.OutboxMessage)
func (o *dispatcherOutbox) ListPending(context.Context, ports.DBTX, int) ([]ports.OutboxMessage, error) {
return o.messages, nil
}

func (o *dispatcherOutbox) MarkDispatched(_ context.Context, _ ports.DBTX, id string, _ time.Time) error {
o.marked = append(o.marked, id)
return nil
Expand Down
4 changes: 1 addition & 3 deletions internal/infrastructure/store/outbox_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,4 @@ func (s *OutboxStore) MarkDispatched(ctx context.Context, q ports.DBTX, id strin
return err
}

var (
_ = sql.NullTime{}
)
var _ = sql.NullTime{}
1 change: 1 addition & 0 deletions internal/infrastructure/worker/worker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func (w *workerOutbox) ListPending(context.Context, ports.DBTX, int) ([]ports.Ou
w.calls++
return nil, nil
}

func (w *workerOutbox) MarkDispatched(context.Context, ports.DBTX, string, time.Time) error {
return nil
}
Expand Down
1 change: 1 addition & 0 deletions internal/transport/http/handlers/error_paths_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func (errorOutboxRepo) Add(context.Context, ports.DBTX, ports.OutboxMessage) err
func (errorOutboxRepo) ListPending(context.Context, ports.DBTX, int) ([]ports.OutboxMessage, error) {
return nil, nil
}

func (errorOutboxRepo) MarkDispatched(context.Context, ports.DBTX, string, time.Time) error {
return nil
}
Expand Down
86 changes: 84 additions & 2 deletions internal/transport/http/handlers/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,24 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/go-chi/chi/v5"
"go.opentelemetry.io/otel/trace/noop"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"

"github.com/froppa/orders-service/internal/application/commands"
"github.com/froppa/orders-service/internal/application/ports"
"github.com/froppa/orders-service/internal/application/queries"
"github.com/froppa/orders-service/internal/domain/orders"
"github.com/froppa/orders-service/internal/observability"
)

type handlerClock struct {
Expand All @@ -40,6 +46,7 @@ func (r *handlerOrdersRepo) Create(_ context.Context, _ ports.DBTX, order orders
r.order = order
return nil
}

func (r *handlerOrdersRepo) GetByID(_ context.Context, _ ports.DBTX, id string) (orders.Order, error) {
if r.order.ID != id {
return orders.Order{}, orders.ErrNotFound
Expand All @@ -53,24 +60,99 @@ func (handlerOutboxRepo) Add(context.Context, ports.DBTX, ports.OutboxMessage) e
func (handlerOutboxRepo) ListPending(context.Context, ports.DBTX, int) ([]ports.OutboxMessage, error) {
return nil, nil
}

func (handlerOutboxRepo) MarkDispatched(context.Context, ports.DBTX, string, time.Time) error {
return nil
}

func TestHealthHandlers(t *testing.T) {
ready := NewHealthHandler(func(context.Context) error { return nil })
ready := NewHealthHandler(zap.NewNop(), func(context.Context) error { return nil })
healthResp := httptest.NewRecorder()
ready.Healthz(healthResp, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if healthResp.Code != http.StatusOK {
t.Fatalf("health status = %d", healthResp.Code)
}

notReady := NewHealthHandler(func(context.Context) error { return context.DeadlineExceeded })
const readyCause = "10.1.2.3:5432"
notReady := NewHealthHandler(zap.NewNop(), func(context.Context) error {
return errors.New("dial tcp " + readyCause + ": connect: connection refused")
})
readyResp := httptest.NewRecorder()
notReady.Readyz(readyResp, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if readyResp.Code != http.StatusServiceUnavailable {
t.Fatalf("ready status = %d", readyResp.Code)
}
if strings.Contains(readyResp.Body.String(), readyCause) {
t.Fatalf("readyz body leaked the readiness cause: %s", readyResp.Body.String())
}

var readyBody struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(readyResp.Body.Bytes(), &readyBody); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if readyBody.Error.Code != "not_ready" {
t.Fatalf("error code = %q, want not_ready", readyBody.Error.Code)
}
}

func TestReadyzLogsCauseInsteadOfReturningIt(t *testing.T) {
const causeDetail = "10.0.0.7:5432"
core, logs := observer.New(zapcore.DebugLevel)
handler := NewHealthHandler(zap.New(core), func(context.Context) error {
return errors.New("dial tcp " + causeDetail + ": connect: connection refused")
})

req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
req = req.WithContext(observability.WithRequestID(req.Context(), "req-1"))
resp := httptest.NewRecorder()
handler.Readyz(resp, req)

if resp.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d", resp.Code)
}
if strings.Contains(resp.Body.String(), causeDetail) {
t.Fatalf("response body leaked the readiness error: %s", resp.Body.String())
}

var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]any `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if body.Error.Code != "not_ready" || body.Error.Message != "service is not ready" {
t.Fatalf("error = %+v", body.Error)
}
if body.Error.Details != nil {
t.Fatalf("details = %v, want nil", body.Error.Details)
}

entries := logs.All()
if len(entries) != 1 {
t.Fatalf("log entries = %d, want 1", len(entries))
}
entry := entries[0]
if entry.Level != zapcore.WarnLevel {
t.Fatalf("level = %s, want warn", entry.Level)
}
if strings.Contains(entry.Message, causeDetail) {
t.Fatalf("log message embeds the cause instead of a zap.Error field: %s", entry.Message)
}
fields := entry.ContextMap()
if loggedErr, _ := fields["error"].(string); !strings.Contains(loggedErr, causeDetail) {
t.Fatalf("error field = %v, want the readiness error", fields["error"])
}
if fields["request_id"] != "req-1" {
t.Fatalf("request_id = %v, want req-1", fields["request_id"])
}
}

func TestOrdersHandlers(t *testing.T) {
Expand Down
16 changes: 10 additions & 6 deletions internal/transport/http/handlers/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@ import (
"errors"
"net/http"

"go.uber.org/zap"

"github.com/froppa/orders-service/internal/domain/orders"
"github.com/froppa/orders-service/internal/observability"
)

type HealthHandler struct {
ready func(context.Context) error
logger *zap.Logger
ready func(context.Context) error
}

func NewHealthHandler(ready func(context.Context) error) *HealthHandler {
return &HealthHandler{ready: ready}
func NewHealthHandler(logger *zap.Logger, ready func(context.Context) error) *HealthHandler {
return &HealthHandler{logger: logger, ready: ready}
}

func (h *HealthHandler) Healthz(w http.ResponseWriter, _ *http.Request) {
Expand All @@ -23,18 +27,18 @@ func (h *HealthHandler) Healthz(w http.ResponseWriter, _ *http.Request) {

func (h *HealthHandler) Readyz(w http.ResponseWriter, r *http.Request) {
if err := h.ready(r.Context()); err != nil {
writeError(w, http.StatusServiceUnavailable, "not_ready", "service is not ready", map[string]any{"cause": err.Error()})
observability.WithContext(r.Context(), h.logger).Warn("readiness check failed", zap.Error(err))
writeError(w, http.StatusServiceUnavailable, "not_ready", "service is not ready")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}

func writeError(w http.ResponseWriter, status int, code, message string, details map[string]any) {
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, map[string]any{
"error": map[string]any{
"code": code,
"message": message,
"details": details,
},
})
}
Expand Down
4 changes: 2 additions & 2 deletions internal/transport/http/handlers/orders_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func NewOrdersCreateHandler(useCase *commands.CreateOrderHandler) *OrdersCreateH
func (h *OrdersCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var req createOrderRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", "request body must be valid JSON", nil)
writeError(w, http.StatusBadRequest, "invalid_json", "request body must be valid JSON")
return
}
order, err := h.useCase.Handle(r.Context(), commands.CreateOrderCommand{
Expand All @@ -31,7 +31,7 @@ func (h *OrdersCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
})
if err != nil {
status, code, message := mapDomainError(err)
writeError(w, status, code, message, nil)
writeError(w, status, code, message)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
Expand Down
2 changes: 1 addition & 1 deletion internal/transport/http/handlers/orders_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func (h *OrdersGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
order, err := h.useCase.Handle(r.Context(), chi.URLParam(r, "id"))
if err != nil {
status, code, message := mapDomainError(err)
writeError(w, status, code, message, nil)
writeError(w, status, code, message)
return
}
writeJSON(w, http.StatusOK, map[string]any{
Expand Down
3 changes: 3 additions & 0 deletions internal/transport/http/middleware/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type middlewareDBTX struct{}
func (middlewareDBTX) ExecContext(context.Context, string, ...any) (sql.Result, error) {
return nil, nil
}

func (middlewareDBTX) QueryContext(context.Context, string, ...any) (*sql.Rows, error) {
return nil, nil
}
Expand All @@ -45,13 +46,15 @@ func (m *memoryIdempotency) Get(_ context.Context, _ ports.DBTX, key string) (po
}
return ports.IdempotencyRecord{}, ports.ErrIdempotencyKeyNotFound
}

func (m *memoryIdempotency) Reserve(_ context.Context, _ ports.DBTX, record ports.IdempotencyRecord) (bool, error) {
if _, ok := m.records[record.Key]; ok {
return false, nil
}
m.records[record.Key] = record
return true, nil
}

func (m *memoryIdempotency) Finalize(_ context.Context, _ ports.DBTX, key string, responseCode int, responseBody []byte) error {
record := m.records[key]
record.ResponseCode = responseCode
Expand Down
5 changes: 3 additions & 2 deletions internal/transport/http/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ func (memoryOutboxRepo) Add(context.Context, ports.DBTX, ports.OutboxMessage) er
func (memoryOutboxRepo) ListPending(context.Context, ports.DBTX, int) ([]ports.OutboxMessage, error) {
return nil, nil
}

func (memoryOutboxRepo) MarkDispatched(context.Context, ports.DBTX, string, time.Time) error {
return nil
}
Expand Down Expand Up @@ -122,7 +123,7 @@ func TestCreateOrderAndReplayIdempotency(t *testing.T) {
Metrics: metrics,
Idempotency: &memoryIdempotencyRepo{records: make(map[string]ports.IdempotencyRecord)},
Transactor: transactor,
HealthHandler: handlers.NewHealthHandler(func(context.Context) error { return nil }),
HealthHandler: handlers.NewHealthHandler(logger, func(context.Context) error { return nil }),
CreateOrder: createHandler,
GetOrder: getHandler,
})
Expand Down Expand Up @@ -183,7 +184,7 @@ func TestIdempotencyConflict(t *testing.T) {
Metrics: metrics,
Idempotency: &memoryIdempotencyRepo{records: make(map[string]ports.IdempotencyRecord)},
Transactor: transactor,
HealthHandler: handlers.NewHealthHandler(func(context.Context) error { return nil }),
HealthHandler: handlers.NewHealthHandler(logger, func(context.Context) error { return nil }),
CreateOrder: handlers.NewOrdersCreateHandler(commands.NewCreateOrderHandler(testClock{now: time.Now()}, transactor, ordersRepo, memoryOutboxRepo{}, tracer)),
GetOrder: handlers.NewOrdersGetHandler(queries.NewGetOrderHandler(transactor, ordersRepo, tracer)),
})
Expand Down
Loading