feat(server): add GET /api/v1/uptime endpoint #67

Merged
arcodange merged 1 commits from vibe/batch2-task-b-uptime into main 2026-05-05 19:18:25 +02:00
2 changed files with 108 additions and 0 deletions

View File

@@ -246,6 +246,9 @@ func (s *Server) registerApiV1Routes(r chi.Router) {
r.Get("/{name}", s.handleGreetPath) r.Get("/{name}", s.handleGreetPath)
}) })
// Uptime endpoint
r.Get("/uptime", s.handleUptime)
// Register user authentication routes // Register user authentication routes
if s.userService != nil && s.userRepo != nil { if s.userService != nil && s.userRepo != nil {
// Use unified user service - much simpler! // Use unified user service - much simpler!
@@ -583,6 +586,30 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
w.Write(data) w.Write(data)
} }
// UptimeResponse represents the JSON response for /api/v1/uptime
type UptimeResponse struct {
StartTime string `json:"start_time"`
UptimeSeconds int `json:"uptime_seconds"`
}
// handleUptime godoc
//
// @Summary Get server uptime
// @Description Returns server start time and uptime duration
// @Tags System/Info
// @Produce json
// @Success 200 {object} UptimeResponse
// @Router /v1/uptime [get]
func (s *Server) handleUptime(w http.ResponseWriter, r *http.Request) {
log.Trace().Msg("Uptime check requested")
resp := UptimeResponse{
StartTime: s.startedAt.Format(time.RFC3339),
UptimeSeconds: int(time.Since(s.startedAt).Seconds()),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// handleGreetQuery godoc // handleGreetQuery godoc
// //
// @Summary Get greeting with cache // @Summary Get greeting with cache

81
pkg/server/uptime_test.go Normal file
View File

@@ -0,0 +1,81 @@
package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"dance-lessons-coach/pkg/config"
"github.com/stretchr/testify/assert"
)
func TestHandleUptime(t *testing.T) {
// Setup with a known start time
cfg := &config.Config{}
// We need to create a server and then set its startedAt to a known time
// Since NewServer sets startedAt to time.Now(), we'll create the server
// and then use reflection or we can use NewServerWithUserRepo which also sets startedAt
s := NewServer(cfg, context.Background())
// Set a fixed start time for deterministic testing
// We can't directly set s.startedAt since it's unexported, but we can test
// that the handler uses the server's startedAt
// The test will verify the structure and that uptime_seconds is >= 0
// Create request
req := httptest.NewRequest(http.MethodGet, "/api/v1/uptime", nil)
w := httptest.NewRecorder()
// Call handler
s.handleUptime(w, req)
// Check status code
assert.Equal(t, http.StatusOK, w.Code)
// Check content type
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
// Decode response
var resp UptimeResponse
err := json.NewDecoder(w.Body).Decode(&resp)
assert.NoError(t, err)
// Assert fields
assert.NotEmpty(t, resp.StartTime)
// Verify start_time is in RFC3339 format
_, err = time.Parse(time.RFC3339, resp.StartTime)
assert.NoError(t, err)
assert.GreaterOrEqual(t, resp.UptimeSeconds, 0)
}
func TestHandleUptime_Deterministic(t *testing.T) {
// For a more deterministic test, we would need to be able to set startedAt
// Since startedAt is unexported, we test the behavior with a known server
// that was just created (uptime should be very small)
cfg := &config.Config{}
s := NewServer(cfg, context.Background())
// Small delay to ensure uptime is at least 0 seconds
time.Sleep(10 * time.Millisecond)
req := httptest.NewRequest(http.MethodGet, "/api/v1/uptime", nil)
w := httptest.NewRecorder()
s.handleUptime(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp UptimeResponse
err := json.NewDecoder(w.Body).Decode(&resp)
assert.NoError(t, err)
// Uptime should be at least 0 (it's int() of seconds, so minimum is 0)
assert.GreaterOrEqual(t, resp.UptimeSeconds, 0)
// Start time should be parseable
_, err = time.Parse(time.RFC3339, resp.StartTime)
assert.NoError(t, err)
}