Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
5 changes: 3 additions & 2 deletions backend/api/azure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package main

import (
"context"
"os"
"testing"

Expand Down Expand Up @@ -68,13 +69,13 @@ func TestAzureTableIntegration(t *testing.T) {
healthChecker := health.New()

// For Azure Table, we add a mock health check since the repo is nil
healthChecker.AddCheck("database", func() error {
healthChecker.AddCheck("database", func(_ context.Context) error {
return nil // Mock check since we expect repo to be nil in this test
})

// The health check should return UP since we're using a mock check
healthChecker.SetReady(true)
status := healthChecker.CheckReadiness()
status := healthChecker.CheckReadiness(context.Background())
assert.Equal(t, "UP", status.Status)
})

Expand Down
55 changes: 35 additions & 20 deletions backend/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
"backend/internal/health"
"context"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
Expand All @@ -23,7 +23,7 @@ import (
)

const (
gracefulShutdownTimeout = 5 * time.Second
defaultShutdownTimeout = 5 * time.Second
)

// @title Backend API
Expand All @@ -38,22 +38,28 @@ func main() {
// Load configuration
cfg, err := config.LoadConfig()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
slog.Error("Failed to load configuration", "error", err)
os.Exit(1)
}

// Initialize database
db, err := database.NewFromAppConfig(cfg)
// Initialize repository using the factory (selects MySQL or Azure Table based on config)
repo, err := database.NewRepository(cfg)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
slog.Error("Failed to initialize repository", "error", err)
os.Exit(1)
}
Comment on lines +45 to 50
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The database initialization code has been removed (lines 45-50 in old code), but the migration logic that depends on it is not visible in this diff. If migrations are still needed, they should be run after the repository is created. Verify that AutoMigrate() is still being called somewhere, or document that migrations are now optional when using Azure Table Storage.

Copilot uses AI. Check for mistakes.

// Initialize health checker
// Initialize health checker with actual database dependency
healthChecker := health.New()
healthChecker.AddCheck("database", db.Ping)
healthChecker.AddCheck("database", func(ctx context.Context) error {
return repo.Ping(ctx)
})
healthChecker.SetReady(true)

// Setup router
router := gin.Default()
routes.SetupRoutes(router, db)
// Setup router — use gin.New() since SetupRoutes registers its own Logger and Recovery middleware.
router := gin.New()
rateLimiter := routes.SetupRoutes(router, repo, healthChecker, cfg)
defer rateLimiter.Stop()
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))

// Create server with timeouts
Expand All @@ -67,29 +73,38 @@ func main() {

// Start server in a goroutine
go func() {
slog.Info("Server starting", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Failed to start server: %v", err)
slog.Error("Failed to start server", "error", err)
os.Exit(1)
}
}()

// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
slog.Info("Shutting down server...")

// Give outstanding requests 5 seconds to complete
ctx, cancel := context.WithTimeout(context.Background(), gracefulShutdownTimeout)
// Give outstanding requests time to complete
shutdownTimeout := cfg.Server.ShutdownTimeout
if shutdownTimeout == 0 {
shutdownTimeout = defaultShutdownTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()

err = srv.Shutdown(ctx)
// Always execute cleanup
cancel()

// Close repository connections (database pool, etc.)
if closeErr := repo.Close(); closeErr != nil {
slog.Error("Failed to close repository", "error", closeErr)
}

if err != nil {
log.Printf("Server forced to shutdown: %v", err)
return // Return with error status from main
slog.Error("Server forced to shutdown", "error", err)
return
}
Comment on lines 97 to 107
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The database connection is not closed during graceful shutdown. When using MySQL, the repository wraps a *gorm.DB which has an underlying *sql.DB connection pool. This connection pool should be closed when the server shuts down to avoid resource leaks.

Consider adding cleanup before the server shutdown completes:

// Close database connections if using MySQL
if sqlDB, err := repo.(*models.GenericRepository).DB.DB(); err == nil {
    sqlDB.Close()
}

However, the Repository interface doesn't expose the underlying DB, so you may need to add a Close() error method to the Repository interface and implement it for both MySQL and Azure backends.

Copilot uses AI. Check for mistakes.

log.Println("Server exiting")
slog.Info("Server exited gracefully")
}
51 changes: 28 additions & 23 deletions backend/api/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,32 +38,37 @@ type MockRepository struct {
mock.Mock
}

func (m *MockRepository) Create(entity interface{}) error {
args := m.Called(entity)
func (m *MockRepository) Create(ctx context.Context, entity interface{}) error {
args := m.Called(ctx, entity)
return args.Error(0)
}

func (m *MockRepository) FindByID(id uint, dest interface{}) error {
args := m.Called(id, dest)
func (m *MockRepository) FindByID(ctx context.Context, id uint, dest interface{}) error {
args := m.Called(ctx, id, dest)
return args.Error(0)
}

func (m *MockRepository) Update(entity interface{}) error {
args := m.Called(entity)
func (m *MockRepository) Update(ctx context.Context, entity interface{}) error {
args := m.Called(ctx, entity)
return args.Error(0)
}

func (m *MockRepository) Delete(entity interface{}) error {
args := m.Called(entity)
func (m *MockRepository) Delete(ctx context.Context, entity interface{}) error {
args := m.Called(ctx, entity)
return args.Error(0)
}

func (m *MockRepository) List(dest interface{}, conditions ...interface{}) error {
args := m.Called(dest, conditions)
func (m *MockRepository) List(ctx context.Context, dest interface{}, conditions ...interface{}) error {
args := m.Called(ctx, dest, conditions)
return args.Error(0)
}

func (m *MockRepository) Ping() error {
func (m *MockRepository) Ping(ctx context.Context) error {
args := m.Called(ctx)
return args.Error(0)
}

func (m *MockRepository) Close() error {
args := m.Called()
return args.Error(0)
}
Expand Down Expand Up @@ -181,12 +186,12 @@ func TestHealthEndpoints(t *testing.T) {

// Register health endpoints
r.GET("/health/live", func(c *gin.Context) {
status := healthChecker.CheckLiveness()
status := healthChecker.CheckLiveness(c.Request.Context())
c.JSON(http.StatusOK, status)
})

r.GET("/health/ready", func(c *gin.Context) {
status := healthChecker.CheckReadiness()
status := healthChecker.CheckReadiness(c.Request.Context())
if status.Status == "DOWN" {
c.JSON(http.StatusServiceUnavailable, status)
return
Expand All @@ -211,7 +216,7 @@ func TestHealthEndpoints(t *testing.T) {
assert.Contains(t, w.Body.String(), "UP")

// Test with failing health check
healthChecker.AddCheck("test", func() error {
healthChecker.AddCheck("test", func(_ context.Context) error {
return errors.New("test error")
})

Expand All @@ -230,36 +235,36 @@ func TestDatabaseHealthCheck(t *testing.T) {

// Test with SQLite database
mockRepo := new(MockRepository)
mockRepo.On("Ping").Return(nil)
mockRepo.On("Ping", mock.Anything).Return(nil)

// Add database health check
healthChecker.AddCheck("database", func() error {
return mockRepo.Ping()
healthChecker.AddCheck("database", func(_ context.Context) error {
return mockRepo.Ping(context.Background())
})

// Check readiness
status := healthChecker.CheckReadiness()
status := healthChecker.CheckReadiness(context.Background())
assert.Equal(t, "DOWN", status.Status) // Initially DOWN because we haven't set ready

// Mark as ready
healthChecker.SetReady(true)

// Check again
status = healthChecker.CheckReadiness()
status = healthChecker.CheckReadiness(context.Background())
assert.Equal(t, "UP", status.Status)
assert.Equal(t, "UP", status.Checks["database"].Status)

// Test with failing database connection
mockRepo = new(MockRepository)
mockRepo.On("Ping").Return(errors.New("connection failed"))
mockRepo.On("Ping", mock.Anything).Return(errors.New("connection failed"))

healthChecker = health.New()
healthChecker.SetReady(true)
healthChecker.AddCheck("database", func() error {
return mockRepo.Ping()
healthChecker.AddCheck("database", func(_ context.Context) error {
return mockRepo.Ping(context.Background())
})

status = healthChecker.CheckReadiness()
status = healthChecker.CheckReadiness(context.Background())
assert.Equal(t, "DOWN", status.Status)
assert.Equal(t, "DOWN", status.Checks["database"].Status)
assert.Contains(t, status.Checks["database"].Message, "connection failed")
Expand Down
59 changes: 19 additions & 40 deletions backend/internal/api/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,6 @@ import (
"github.com/gin-gonic/gin"
)

var healthChecker *health.HealthChecker

func init() {
healthChecker = health.New()
// Set the service as ready after initialization
healthChecker.SetReady(true)
}

// @Summary Health Check
// @Description Get API health status
// @Tags health
Expand All @@ -35,52 +27,39 @@ func Ping(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "pong"})
}

// @Summary Get items
// @Description Get all items
// @Tags items
// @Produce json
// @Success 200 {object} map[string]string
// @Router /api/v1/items [get]
func GetItems(c *gin.Context) {
// Logic to retrieve items goes here
c.JSON(http.StatusOK, gin.H{"message": "GetItems called"})
}

// @Summary Create item
// @Description Create a new item
// @Tags items
// @Accept json
// @Produce json
// @Success 201 {object} map[string]string
// @Router /api/v1/items [post]
func CreateItem(c *gin.Context) {
// Logic to create an item goes here
c.JSON(http.StatusCreated, gin.H{"message": "CreateItem called"})
}

// LivenessHandler returns a handler for liveness checks.
// The health checker is injected so the same instance used in main is checked.
//
// @Summary Liveness Check
// @Description Get API liveness status
// @Tags health
// @Produce json
// @Success 200 {object} health.HealthStatus
// @Router /health/live [get]
func LivenessCheck(c *gin.Context) {
status := healthChecker.CheckLiveness()
c.JSON(http.StatusOK, status)
func LivenessHandler(hc *health.HealthChecker) gin.HandlerFunc {
return func(c *gin.Context) {
status := hc.CheckLiveness(c.Request.Context())
c.JSON(http.StatusOK, status)
}
}

// ReadinessHandler returns a handler for readiness checks.
// The health checker is injected so the same instance used in main is checked.
//
// @Summary Readiness Check
// @Description Get API readiness status
// @Tags health
// @Produce json
// @Success 200 {object} health.HealthStatus
// @Failure 503 {object} health.HealthStatus
// @Router /health/ready [get]
func ReadinessCheck(c *gin.Context) {
status := healthChecker.CheckReadiness()
if status.Status == "DOWN" {
c.JSON(http.StatusServiceUnavailable, status)
return
func ReadinessHandler(hc *health.HealthChecker) gin.HandlerFunc {
return func(c *gin.Context) {
status := hc.CheckReadiness(c.Request.Context())
if status.Status == "DOWN" {
c.JSON(http.StatusServiceUnavailable, status)
return
}
c.JSON(http.StatusOK, status)
}
c.JSON(http.StatusOK, status)
}
40 changes: 0 additions & 40 deletions backend/internal/api/handlers/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,43 +61,3 @@ func TestPingHandler(t *testing.T) {
assert.Nil(t, err)
assert.Equal(t, "pong", response["message"])
}
func TestGetItemsHandler(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)

r := gin.Default()
r.GET("/items", GetItems)

w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/items", nil)

r.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)

var response map[string]string
err := json.Unmarshal(w.Body.Bytes(), &response)

assert.Nil(t, err)
assert.Equal(t, "GetItems called", response["message"])
}
func TestCreateItemHandler(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)

r := gin.Default()
r.POST("/items", CreateItem)

w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/items", nil)

r.ServeHTTP(w, req)

assert.Equal(t, http.StatusCreated, w.Code)

var response map[string]string
err := json.Unmarshal(w.Body.Bytes(), &response)

assert.Nil(t, err)
assert.Equal(t, "CreateItem called", response["message"])
}
Loading