diff --git a/CLAUDE.md b/CLAUDE.md index 2e6c4ce..697b370 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,12 +58,24 @@ Each doc file has `last_verified` and `sources` frontmatter. The `docs` agent ma ## Project layout ``` backend/ - cmd/api/main.go # entry point — wiring only, no logic + cmd/api/main.go # entry point — wires layers, graceful shutdown internal/ - server/server.go # Server struct, NewServer() - server/routes.go # RegisterRoutes(), all handlers as *Server methods - database/database.go # Service interface + implementation, all queries - database/database_test.go # integration tests (Testcontainers) + domain/ # Layer 1: entities + repository interfaces (no external deps) + health.go # HealthStats type + usecase/ # Layer 2: application logic + health_usecase.go # HealthReader interface, HealthUseCase interface + impl + repository/postgres/ # Layer 3: DB implementations + db.go # DBConfig, NewPostgresDB() → *sql.DB + health_repository.go # implements HealthReader + health_repository_test.go # integration tests (Testcontainers) + handler/ # Layer 3: HTTP adapters + handler.go # Handler struct, NewHandler() + routes.go # RegisterRoutes() on *Handler + hello_handler.go # HelloWorldHandler + health_handler.go # healthHandler (503 when DB down) + hello_handler_test.go # httptest unit tests + server/ + server.go # NewServer() — wires all layers, returns *http.Server docker-compose.yml .env # never commit secrets Makefile @@ -78,10 +90,11 @@ mobile/ CLAUDE.md → AGENTS.md # mobile-specific rules (read before writing Kotlin/Compose) ``` -## Go conventions -- Business logic in `internal/` only. `cmd/` just wires things together. -- New query → add to `Service` interface, implement on `service`, test in `database_test.go`. -- New route → register in `RegisterRoutes()`, handler as method on `*Server`. +## Go conventions (Clean Architecture) +- Follow the dependency rule: `domain` ← `usecase` ← `handler`/`repository` ← `server` ← `cmd`. +- New feature → add entity to `domain/`, interface to `usecase/`, implementation to `repository/postgres/`, handler to `handler/`, wire in `server/server.go`. +- New route → add use case interface + impl in `usecase/`, handler method on `*Handler`, register in `handler/routes.go`, wire in `server/server.go`. +- Repository interfaces live in `usecase/` (the layer that depends on them), not in `repository/`. - Return errors up the stack. Never `log.Fatal` or `os.Exit` inside `internal/`. - Run `go vet ./...` before committing. @@ -102,8 +115,9 @@ mobile/ ## Testing — non-negotiable - **Never mock the database.** Always use Testcontainers. -- Follow the `TestMain` + `mustStartPostgresContainer()` pattern in `database_test.go`. -- Tests live in the same package as the code (`package database`, `package server`). +- Follow the `TestMain` + `mustStartPostgresContainer()` pattern in `repository/postgres/health_repository_test.go`. +- DB integration tests live in `internal/repository/postgres/` (`package postgres`). +- Handler unit tests live in `internal/handler/` and may use mock use cases — that is not mocking the database. - Docker must be running for integration tests. ## Hard rules (hooks enforce some of these) diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..d49dcfc --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,9 @@ +PORT=8080 +APP_ENV=local +BLUEPRINT_DB_HOST=localhost +BLUEPRINT_DB_PORT=5432 +BLUEPRINT_DB_DATABASE=blueprint +BLUEPRINT_DB_USERNAME= +BLUEPRINT_DB_PASSWORD= +BLUEPRINT_DB_SCHEMA=public +BLUEPRINT_DB_SSLMODE=disable diff --git a/backend/Makefile b/backend/Makefile index 0c2392a..c46b538 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -27,7 +27,7 @@ test: # Integrations Tests for the application itest: @echo "Running integration tests..." - @go test ./internal/database -v + @go test ./internal/repository/postgres/... -v # Clean the binary clean: diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index d009757..6046f36 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -13,18 +13,14 @@ import ( ) func gracefulShutdown(apiServer *http.Server, done chan bool) { - // Create context that listens for the interrupt signal from the OS. ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - // Listen for the interrupt signal. <-ctx.Done() log.Println("shutting down gracefully, press Ctrl+C again to force") - stop() // Allow Ctrl+C to force shutdown + stop() - // The context is used to inform the server it has 5 seconds to finish - // the request it is currently handling ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := apiServer.Shutdown(ctx); err != nil { @@ -32,27 +28,22 @@ func gracefulShutdown(apiServer *http.Server, done chan bool) { } log.Println("Server exiting") - - // Notify the main goroutine that the shutdown is complete done <- true } func main() { + srv, err := server.NewServer() + if err != nil { + log.Fatalf("failed to initialize server: %v", err) + } - server := server.NewServer() - - // Create a done channel to signal when the shutdown is complete done := make(chan bool, 1) + go gracefulShutdown(srv, done) - // Run graceful shutdown in a separate goroutine - go gracefulShutdown(server, done) - - err := server.ListenAndServe() - if err != nil && err != http.ErrServerClosed { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { panic(fmt.Sprintf("http server error: %s", err)) } - // Wait for the graceful shutdown to complete <-done log.Println("Graceful shutdown complete.") } diff --git a/backend/docs/_index.md b/backend/docs/_index.md index be25d8f..e4d3145 100644 --- a/backend/docs/_index.md +++ b/backend/docs/_index.md @@ -5,8 +5,8 @@ The `docs` agent reads this index first to locate the right file before diving i | Topic | File | Source files covered | |---|---|---| -| Database connection & query patterns | [database.md](database.md) | `internal/database/database.go` | -| HTTP routing & handler patterns | [routing.md](routing.md) | `internal/server/routes.go`, `internal/server/server.go` | -| Integration testing with Testcontainers | [testing.md](testing.md) | `internal/database/database_test.go` | -| Error handling conventions | [error-handling.md](error-handling.md) | `internal/database/database.go`, `cmd/api/main.go` | -| Environment variables | [environment.md](environment.md) | `.env`, `internal/database/database.go`, `internal/server/server.go` | +| Database connection & query patterns | [database.md](database.md) | `internal/repository/postgres/db.go`, `internal/repository/postgres/health_repository.go`, `internal/domain/health.go`, `internal/usecase/health_usecase.go` | +| HTTP routing & handler patterns | [routing.md](routing.md) | `internal/handler/handler.go`, `internal/handler/routes.go`, `internal/handler/hello_handler.go`, `internal/handler/health_handler.go`, `internal/server/server.go` | +| Integration testing with Testcontainers | [testing.md](testing.md) | `internal/repository/postgres/health_repository_test.go`, `internal/handler/hello_handler_test.go` | +| Error handling conventions | [error-handling.md](error-handling.md) | `internal/repository/postgres/health_repository.go`, `internal/handler/health_handler.go`, `cmd/api/main.go` | +| Environment variables | [environment.md](environment.md) | `.env`, `internal/repository/postgres/db.go`, `internal/server/server.go` | diff --git a/backend/docs/database.md b/backend/docs/database.md index 7a82674..af1de7a 100644 --- a/backend/docs/database.md +++ b/backend/docs/database.md @@ -2,69 +2,105 @@ topic: database last_verified: 2026-06-14 sources: - - internal/database/database.go - - internal/database/database_test.go + - internal/domain/health.go + - internal/usecase/health_usecase.go + - internal/repository/postgres/db.go + - internal/repository/postgres/health_repository.go --- # Database ## Driver `database/sql` standard library with pgx v5 as the stdlib driver. -Import: `_ "github.com/jackc/pgx/v5/stdlib"` (blank import, registers the driver). +Import: `_ "github.com/jackc/pgx/v5/stdlib"` (blank import in `db.go`, registers the driver). Do NOT use pgx's native API — use `database/sql` methods only. ## Connection -Singleton pattern via package-level `dbInstance *service` var. -`New()` returns early if `dbInstance != nil`. +No singleton. `NewPostgresDB` is called once in `internal/server/server.go` and the returned `*sql.DB` is passed down the dependency chain. The caller is responsible for calling `db.Close()` on shutdown. + +`DBConfig` holds all connection parameters: + +```go +type DBConfig struct { + Host string + Port string + Database string + Username string + Password string + Schema string + SSLMode string +} +``` + +`NewPostgresDB(cfg DBConfig) (*sql.DB, error)` builds the connection string and calls `sql.Open`. It returns an error instead of calling `log.Fatal`. Connection string format: ``` postgres://username:password@host:port/database?sslmode=disable&search_path=schema ``` -Env vars loaded automatically via `_ "github.com/joho/godotenv/autoload"` blank import in `database.go`. +Env vars are loaded via `_ "github.com/joho/godotenv/autoload"` blank import in `internal/server/server.go` only. + +## Architecture layers + +``` +internal/domain/health.go — HealthStats type (map[string]string alias) +internal/usecase/health_usecase.go — HealthReader interface (repo contract), HealthUseCase interface, healthUseCase impl +internal/repository/postgres/ — HealthRepository: implements HealthReader against *sql.DB +``` -## Service interface -All DB operations are defined as methods on the `Service` interface. -The `service` struct is the private implementation. +The `HealthReader` interface is defined in the `usecase` package (Dependency Inversion — the use case owns the interface it depends on): ```go -type Service interface { - Health() map[string]string - Close() error - // Add new methods here +// usecase/health_usecase.go +type HealthReader interface { + Health(ctx context.Context) (domain.HealthStats, error) +} + +type HealthUseCase interface { + GetHealth(ctx context.Context) (domain.HealthStats, error) } +``` + +## Repository pattern +Each repository is a struct that holds `*sql.DB` and is constructed with a `New*` function. -type service struct { +```go +type HealthRepository struct { db *sql.DB } + +func NewHealthRepository(db *sql.DB) *HealthRepository { + return &HealthRepository{db: db} +} ``` ## Adding a new query — exact pattern -1. Add method signature to `Service` interface. -2. Implement on `*service` using `s.db.QueryContext` / `s.db.QueryRowContext` / `s.db.ExecContext`. -3. Always use parameterized queries — `$1`, `$2`, etc. Never string-concatenate SQL. -4. Add integration test in `database_test.go`. +1. Define a domain type in `internal/domain/` if needed. +2. Add a repository interface in the relevant `usecase/` file (the use case owns the interface). +3. Implement the repository in `internal/repository/postgres/` as a struct with a `New*` constructor. +4. Use `db.QueryContext` / `db.QueryRowContext` / `db.ExecContext`. Always pass `ctx`. +5. Always use parameterized queries — `$1`, `$2`, etc. Never string-concatenate SQL. +6. Return `(Result, error)` — never swallow errors or call `log.Fatal`. +7. Add integration test in `internal/repository/postgres/`. ```go -// Interface -GetUser(ctx context.Context, id int64) (*User, error) - -// Implementation -func (s *service) GetUser(ctx context.Context, id int64) (*User, error) { - row := s.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id) - var u User +// Repository method +func (r *UserRepository) GetUser(ctx context.Context, id int64) (*domain.User, error) { + row := r.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id) + var u domain.User if err := row.Scan(&u.ID, &u.Name); err != nil { - return nil, err + return nil, fmt.Errorf("postgres: GetUser: %w", err) } return &u, nil } ``` ## Health check -`Health()` returns a `map[string]string` with `status`, `message`, and connection pool stats. -Called by the `/health` route. Calls `s.db.PingContext` with a 1-second timeout. -Note: `Health()` calls `log.Fatalf` on ping failure — this is intentional (terminates on unrecoverable DB loss). +`HealthRepository.Health(ctx)` returns `(domain.HealthStats, error)`. +On ping failure: sets `stats["status"] = "down"` and returns a non-nil error. +On success: sets `stats["status"] = "up"` plus connection pool stats. +The HTTP handler returns 503 when this method returns an error (see routing.md). ## Connection pool stats `Health()` exposes: `open_connections`, `in_use`, `idle`, `wait_count`, `wait_duration`, `max_idle_closed`, `max_lifetime_closed`. diff --git a/backend/docs/error-handling.md b/backend/docs/error-handling.md index e40fb90..ab4067e 100644 --- a/backend/docs/error-handling.md +++ b/backend/docs/error-handling.md @@ -2,8 +2,8 @@ topic: error-handling last_verified: 2026-06-14 sources: - - internal/database/database.go - - internal/server/routes.go + - internal/repository/postgres/health_repository.go + - internal/handler/health_handler.go - cmd/api/main.go --- @@ -11,22 +11,50 @@ sources: ## General rule Return errors up the call stack. Callers decide how to handle them. -Never use `log.Fatal` or `os.Exit` inside `internal/` except for the two documented exceptions below. +Never use `log.Fatal` or `os.Exit` inside `internal/`. -## Documented exceptions (intentional) +## Documented exception (intentional) | Location | Call | Reason | |---|---|---| -| `database.go: New()` | `log.Fatal(err)` | Startup failure — if the DB can't connect at boot, the process should not continue | -| `database.go: Health()` | `log.Fatalf(...)` | Unrecoverable DB loss detected during health check — intentional termination | +| `cmd/api/main.go: main()` | `log.Fatalf(...)` | `server.NewServer()` returned an error — process cannot start | -These are startup/health-check paths only. All other paths in `internal/` return errors. +This is the only permitted `log.Fatal` call and it lives in `cmd/`, not `internal/`. + +## Repository errors +Repository methods return `(Result, error)`. On failure, wrap with context using `fmt.Errorf`: + +```go +func (r *HealthRepository) Health(ctx context.Context) (domain.HealthStats, error) { + if err := r.db.PingContext(pingCtx); err != nil { + stats["status"] = "down" + stats["error"] = fmt.Sprintf("db down: %v", err) + return stats, fmt.Errorf("postgres: health ping: %w", err) + } + // ... + return stats, nil +} +``` ## Handler error responses -In Gin handlers, map errors to HTTP status codes explicitly: +Handlers call use cases, check errors, and map them to HTTP status codes. The health handler returns 503 when the DB is unreachable: + +```go +func (h *Handler) healthHandler(c *gin.Context) { + stats, err := h.healthUC.GetHealth(c.Request.Context()) + if err != nil { + log.Printf("health check failed: %v", err) + c.JSON(http.StatusServiceUnavailable, stats) + return + } + c.JSON(http.StatusOK, stats) +} +``` + +For general handlers, map errors to status codes explicitly: ```go -func (s *Server) getItemHandler(c *gin.Context) { - item, err := s.db.GetItem(c.Request.Context(), id) +func (h *Handler) getItemHandler(c *gin.Context) { + item, err := h.itemUC.GetItem(c.Request.Context(), id) if err != nil { if errors.Is(err, sql.ErrNoRows) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) diff --git a/backend/docs/routing.md b/backend/docs/routing.md index e1abbd1..af5935a 100644 --- a/backend/docs/routing.md +++ b/backend/docs/routing.md @@ -2,69 +2,100 @@ topic: routing last_verified: 2026-06-14 sources: + - internal/handler/handler.go + - internal/handler/routes.go + - internal/handler/hello_handler.go + - internal/handler/health_handler.go - internal/server/server.go - - internal/server/routes.go - cmd/api/main.go --- # Routing -## Server struct +## Handler struct ```go -type Server struct { - port int - db database.Service +// internal/handler/handler.go +type Handler struct { + healthUC usecase.HealthUseCase +} + +func NewHandler(healthUC usecase.HealthUseCase) *Handler { + return &Handler{healthUC: healthUC} +} +``` +The `Handler` struct holds use case interfaces — not `*sql.DB` directly. Add new use case fields here as features are added. + +## Wiring (server.go) +`internal/server/server.go` contains `NewServer() (*http.Server, error)` — wiring only, no logic. +It builds `DBConfig` from env, calls `NewPostgresDB`, constructs the repository, use case, and handler in order, then returns a configured `*http.Server`. + +```go +db, err := postgres.NewPostgresDB(cfg) +healthRepo := postgres.NewHealthRepository(db) +healthUC := usecase.NewHealthUseCase(healthRepo) +h := handler.NewHandler(healthUC) + +srv := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: h.RegisterRoutes(), + IdleTimeout: time.Minute, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, } ``` -`NewServer()` reads `PORT` from env, wires up the `Server`, and returns a configured `*http.Server` with timeouts: -- `IdleTimeout`: 1 minute -- `ReadTimeout`: 10 seconds -- `WriteTimeout`: 30 seconds ## Route registration -All routes registered in `RegisterRoutes()` on `*Server`, which returns `http.Handler`. -`RegisterRoutes()` creates the Gin engine, applies middleware, registers routes, and returns. +All routes registered in `RegisterRoutes()` on `*Handler`, which returns `http.Handler`. ```go -func (s *Server) RegisterRoutes() http.Handler { +func (h *Handler) RegisterRoutes() http.Handler { r := gin.Default() r.Use(cors.New(cors.Config{ ... })) - r.GET("/path", s.myHandler) + r.GET("/path", h.myHandler) return r } ``` ## Handler pattern -All handlers are methods on `*Server`. Always use `*gin.Context`. +All handlers are methods on `*Handler`. Always use `*gin.Context`. ```go -func (s *Server) myHandler(c *gin.Context) { - // access DB via s.db - c.JSON(http.StatusOK, gin.H{"key": "value"}) +func (h *Handler) myHandler(c *gin.Context) { + result, err := h.someUC.DoSomething(c.Request.Context(), ...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + c.JSON(http.StatusOK, result) } ``` ## CORS configuration Pre-configured in `RegisterRoutes()` via `github.com/gin-contrib/cors`. -Current allowed origin: `http://localhost:5173` — **update to `http://localhost:3000`** for this project's Next.js web app. +Current allowed origin: `http://localhost:3000`. Allowed methods: GET, POST, PUT, DELETE, OPTIONS, PATCH. `AllowCredentials: true` — cookies and auth headers pass through. ## Existing routes -| Method | Path | Handler | -|---|---|---| -| GET | `/` | `HelloWorldHandler` — returns `{"message": "Hello World"}` | -| GET | `/health` | `healthHandler` — returns `s.db.Health()` map | +| Method | Path | Handler | File | +|---|---|---|---| +| GET | `/` | `HelloWorldHandler` — returns `{"message": "Hello World"}` | `hello_handler.go` | +| GET | `/health` | `healthHandler` — returns `HealthStats`; 503 when DB is down | `health_handler.go` | ## Graceful shutdown Wired in `cmd/api/main.go` via `signal.NotifyContext` for SIGINT/SIGTERM. 5-second shutdown timeout. Server notifies `done chan bool` when complete. +`main()` handles the error returned by `server.NewServer()` with `log.Fatalf`. Do not add shutdown logic to `internal/` — it belongs in `cmd/`. ## Adding a new route — checklist -1. Register in `RegisterRoutes()`: `r.METHOD("/path", s.handlerName)` -2. Add handler as `func (s *Server) handlerName(c *gin.Context)` -3. If DB is needed, call `s.db.MethodName(c.Request.Context(), ...)` — always pass context -4. For request body: bind with `c.ShouldBindJSON(&input)`, return 400 on error -5. For path params: `c.Param("id")` -6. For query params: `c.Query("key")` +1. Define domain type in `internal/domain/` if needed. +2. Add use case interface + implementation in `internal/usecase/`. +3. Add repository interface in the use case package; implement in `internal/repository/postgres/`. +4. Add use case field to `Handler` struct in `handler.go`; update `NewHandler` signature. +5. Wire the new repository → use case → handler in `server.NewServer()`. +6. Register the route in `RegisterRoutes()`: `r.METHOD("/path", h.handlerName)`. +7. Add handler as `func (h *Handler) handlerName(c *gin.Context)` in its own file. +8. Always pass `c.Request.Context()` to use case calls. +9. For request body: bind with `c.ShouldBindJSON(&input)`, return 400 on error. +10. For path params: `c.Param("id")`. For query params: `c.Query("key")`. diff --git a/backend/docs/testing.md b/backend/docs/testing.md index f45c584..7929c61 100644 --- a/backend/docs/testing.md +++ b/backend/docs/testing.md @@ -2,7 +2,8 @@ topic: testing last_verified: 2026-06-14 sources: - - internal/database/database_test.go + - internal/repository/postgres/health_repository_test.go + - internal/handler/hello_handler_test.go --- # Testing @@ -11,23 +12,29 @@ sources: **No mocks for the database.** All DB tests run against a real PostgreSQL instance spun up by Testcontainers. This catches schema mismatches, query errors, and type coercion issues that mocks hide. ## Testcontainers setup -`mustStartPostgresContainer()` starts a `postgres:latest` container and sets the package-level connection variables (`host`, `port`, `database`, `username`, `password`) so `New()` connects to the test container. +`mustStartPostgresContainer()` starts a `postgres:latest` container, calls `NewPostgresDB(cfg)` with the container's mapped host/port, and assigns the result to the package-level `var testDB *sql.DB`. ```go +var testDB *sql.DB + func mustStartPostgresContainer() (func(context.Context, ...testcontainers.TerminateOption) error, error) { - dbContainer, err := postgres.Run( + container, err := tcpostgres.Run( context.Background(), "postgres:latest", - postgres.WithDatabase("database"), - postgres.WithUsername("user"), - postgres.WithPassword("password"), + tcpostgres.WithDatabase("database"), + tcpostgres.WithUsername("user"), + tcpostgres.WithPassword("password"), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). - WithStartupTimeout(5*time.Second)), + WithStartupTimeout(5*time.Second), + ), ) - // sets host, port, database, username, password package vars - // returns dbContainer.Terminate + // resolves host and mapped port from container + cfg := DBConfig{Host: dbHost, Port: dbPort.Port(), ...} + db, err := NewPostgresDB(cfg) + testDB = db + return container.Terminate, nil } ``` @@ -49,23 +56,42 @@ func TestMain(m *testing.M) { ## Package placement Tests live in the **same package** as the code under test. -- DB tests: `package database` in `internal/database/` -- Server/handler tests (when added): `package server` in `internal/server/` +- Repository tests: `package postgres` in `internal/repository/postgres/` +- Handler tests: `package handler` in `internal/handler/` + +## Handler unit tests +Handlers that have no DB dependency (e.g. `HelloWorldHandler`) use `httptest` without Testcontainers: + +```go +func TestHelloWorldHandler(t *testing.T) { + h := &Handler{} + r := gin.New() + r.GET("/", h.HelloWorldHandler) + + req, _ := http.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { ... } +} +``` ## Running tests ```bash make test # unit + integration (requires Docker) -make itest # integration only -go test ./internal/database -v -run TestHealth # single test +make itest # integration only — runs ./internal/repository/postgres/... +go test ./internal/repository/postgres/... -v -run TestHealth # single test ``` ## Adding a new integration test -1. Add a `TestXxx(t *testing.T)` function in `database_test.go` (or a new `_test.go` file in the same package). -2. Call `New()` to get the service — it will connect to the Testcontainers instance. -3. Use table-driven tests for multiple cases: +1. Add a `TestXxx(t *testing.T)` function in a `_test.go` file under `internal/repository/postgres/` (same package). +2. Construct the repository under test using `testDB`: e.g. `repo := NewHealthRepository(testDB)`. +3. Call repository methods directly and assert on the results. +4. Use table-driven tests for multiple cases: + ```go func TestGetUser(t *testing.T) { - srv := New() + repo := NewUserRepository(testDB) tests := []struct { name string id int64 @@ -76,7 +102,7 @@ func TestGetUser(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := srv.GetUser(context.Background(), tt.id) + _, err := repo.GetUser(context.Background(), tt.id) if (err != nil) != tt.wantErr { t.Errorf("GetUser() error = %v, wantErr %v", err, tt.wantErr) } @@ -85,6 +111,9 @@ func TestGetUser(t *testing.T) { } ``` +## Test ordering note +`internal/repository/postgres/health_repository_test.go` relies on test ordering: `TestNew` → `TestHealth` → `TestClose`. `TestClose` closes `testDB`; any test added after it that uses `testDB` will fail. + ## Requirements - Docker must be running for any integration test. - `go test` runs all tests including integration. Use build tags if you need to separate them in the future. diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go deleted file mode 100644 index 3d6ed18..0000000 --- a/backend/internal/database/database.go +++ /dev/null @@ -1,115 +0,0 @@ -package database - -import ( - "context" - "database/sql" - "fmt" - "log" - "os" - "strconv" - "time" - - _ "github.com/jackc/pgx/v5/stdlib" - _ "github.com/joho/godotenv/autoload" -) - -// Service represents a service that interacts with a database. -type Service interface { - // Health returns a map of health status information. - // The keys and values in the map are service-specific. - Health() map[string]string - - // Close terminates the database connection. - // It returns an error if the connection cannot be closed. - Close() error -} - -type service struct { - db *sql.DB -} - -var ( - database = os.Getenv("BLUEPRINT_DB_DATABASE") - password = os.Getenv("BLUEPRINT_DB_PASSWORD") - username = os.Getenv("BLUEPRINT_DB_USERNAME") - port = os.Getenv("BLUEPRINT_DB_PORT") - host = os.Getenv("BLUEPRINT_DB_HOST") - schema = os.Getenv("BLUEPRINT_DB_SCHEMA") - dbInstance *service -) - -func New() Service { - // Reuse Connection - if dbInstance != nil { - return dbInstance - } - connStr := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable&search_path=%s", username, password, host, port, database, schema) - db, err := sql.Open("pgx", connStr) - if err != nil { - log.Fatal(err) - } - dbInstance = &service{ - db: db, - } - return dbInstance -} - -// Health checks the health of the database connection by pinging the database. -// It returns a map with keys indicating various health statistics. -func (s *service) Health() map[string]string { - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() - - stats := make(map[string]string) - - // Ping the database - err := s.db.PingContext(ctx) - if err != nil { - stats["status"] = "down" - stats["error"] = fmt.Sprintf("db down: %v", err) - log.Fatalf("db down: %v", err) // Log the error and terminate the program - return stats - } - - // Database is up, add more statistics - stats["status"] = "up" - stats["message"] = "It's healthy" - - // Get database stats (like open connections, in use, idle, etc.) - dbStats := s.db.Stats() - stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections) - stats["in_use"] = strconv.Itoa(dbStats.InUse) - stats["idle"] = strconv.Itoa(dbStats.Idle) - stats["wait_count"] = strconv.FormatInt(dbStats.WaitCount, 10) - stats["wait_duration"] = dbStats.WaitDuration.String() - stats["max_idle_closed"] = strconv.FormatInt(dbStats.MaxIdleClosed, 10) - stats["max_lifetime_closed"] = strconv.FormatInt(dbStats.MaxLifetimeClosed, 10) - - // Evaluate stats to provide a health message - if dbStats.OpenConnections > 40 { // Assuming 50 is the max for this example - stats["message"] = "The database is experiencing heavy load." - } - - if dbStats.WaitCount > 1000 { - stats["message"] = "The database has a high number of wait events, indicating potential bottlenecks." - } - - if dbStats.MaxIdleClosed > int64(dbStats.OpenConnections)/2 { - stats["message"] = "Many idle connections are being closed, consider revising the connection pool settings." - } - - if dbStats.MaxLifetimeClosed > int64(dbStats.OpenConnections)/2 { - stats["message"] = "Many connections are being closed due to max lifetime, consider increasing max lifetime or revising the connection usage pattern." - } - - return stats -} - -// Close closes the database connection. -// It logs a message indicating the disconnection from the specific database. -// If the connection is successfully closed, it returns nil. -// If an error occurs while closing the connection, it returns the error. -func (s *service) Close() error { - log.Printf("Disconnected from database: %s", database) - return s.db.Close() -} diff --git a/backend/internal/database/database_test.go b/backend/internal/database/database_test.go deleted file mode 100644 index c36b626..0000000 --- a/backend/internal/database/database_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package database - -import ( - "context" - "log" - "testing" - "time" - - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/modules/postgres" - "github.com/testcontainers/testcontainers-go/wait" -) - -func mustStartPostgresContainer() (func(context.Context, ...testcontainers.TerminateOption) error, error) { - var ( - dbName = "database" - dbPwd = "password" - dbUser = "user" - ) - - dbContainer, err := postgres.Run( - context.Background(), - "postgres:latest", - postgres.WithDatabase(dbName), - postgres.WithUsername(dbUser), - postgres.WithPassword(dbPwd), - testcontainers.WithWaitStrategy( - wait.ForLog("database system is ready to accept connections"). - WithOccurrence(2). - WithStartupTimeout(5*time.Second)), - ) - if err != nil { - return nil, err - } - - database = dbName - password = dbPwd - username = dbUser - - dbHost, err := dbContainer.Host(context.Background()) - if err != nil { - return dbContainer.Terminate, err - } - - dbPort, err := dbContainer.MappedPort(context.Background(), "5432/tcp") - if err != nil { - return dbContainer.Terminate, err - } - - host = dbHost - port = dbPort.Port() - - return dbContainer.Terminate, err -} - -func TestMain(m *testing.M) { - teardown, err := mustStartPostgresContainer() - if err != nil { - log.Fatalf("could not start postgres container: %v", err) - } - - m.Run() - - if teardown != nil && teardown(context.Background()) != nil { - log.Fatalf("could not teardown postgres container: %v", err) - } -} - -func TestNew(t *testing.T) { - srv := New() - if srv == nil { - t.Fatal("New() returned nil") - } -} - -func TestHealth(t *testing.T) { - srv := New() - - stats := srv.Health() - - if stats["status"] != "up" { - t.Fatalf("expected status to be up, got %s", stats["status"]) - } - - if _, ok := stats["error"]; ok { - t.Fatalf("expected error not to be present") - } - - if stats["message"] != "It's healthy" { - t.Fatalf("expected message to be 'It's healthy', got %s", stats["message"]) - } -} - -func TestClose(t *testing.T) { - srv := New() - - if srv.Close() != nil { - t.Fatalf("expected Close() to return nil") - } -} diff --git a/backend/internal/domain/health.go b/backend/internal/domain/health.go new file mode 100644 index 0000000..ac21661 --- /dev/null +++ b/backend/internal/domain/health.go @@ -0,0 +1,15 @@ +package domain + +// HealthStats holds database connection pool statistics returned by the health endpoint. +type HealthStats struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + OpenConnections int `json:"open_connections,omitempty"` + InUse int `json:"in_use,omitempty"` + Idle int `json:"idle,omitempty"` + WaitCount int64 `json:"wait_count,omitempty"` + WaitDuration string `json:"wait_duration,omitempty"` + MaxIdleClosed int64 `json:"max_idle_closed,omitempty"` + MaxLifetimeClosed int64 `json:"max_lifetime_closed,omitempty"` +} diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go new file mode 100644 index 0000000..84e1ea7 --- /dev/null +++ b/backend/internal/handler/handler.go @@ -0,0 +1,15 @@ +package handler + +import ( + "backend/internal/usecase" +) + +// Handler holds all use case dependencies for HTTP handlers. +type Handler struct { + healthUC usecase.HealthUseCase +} + +// NewHandler constructs a Handler with all required use cases. +func NewHandler(healthUC usecase.HealthUseCase) *Handler { + return &Handler{healthUC: healthUC} +} diff --git a/backend/internal/handler/health_handler.go b/backend/internal/handler/health_handler.go new file mode 100644 index 0000000..d1c073b --- /dev/null +++ b/backend/internal/handler/health_handler.go @@ -0,0 +1,18 @@ +package handler + +import ( + "log" + "net/http" + + "github.com/gin-gonic/gin" +) + +func (h *Handler) HealthHandler(c *gin.Context) { + stats, err := h.healthUC.GetHealth(c.Request.Context()) + if err != nil { + log.Printf("health check failed: %v", err) + c.JSON(http.StatusServiceUnavailable, stats) + return + } + c.JSON(http.StatusOK, stats) +} diff --git a/backend/internal/handler/hello_handler.go b/backend/internal/handler/hello_handler.go new file mode 100644 index 0000000..3764e42 --- /dev/null +++ b/backend/internal/handler/hello_handler.go @@ -0,0 +1,11 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +func (h *Handler) HelloWorldHandler(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "Hello World"}) +} diff --git a/backend/internal/server/routes_test.go b/backend/internal/handler/hello_handler_test.go similarity index 51% rename from backend/internal/server/routes_test.go rename to backend/internal/handler/hello_handler_test.go index db2233e..c41d36e 100644 --- a/backend/internal/server/routes_test.go +++ b/backend/internal/handler/hello_handler_test.go @@ -1,32 +1,32 @@ -package server +package handler import ( - "github.com/gin-gonic/gin" "net/http" "net/http/httptest" "testing" + + "github.com/gin-gonic/gin" ) func TestHelloWorldHandler(t *testing.T) { - s := &Server{} + h := &Handler{} r := gin.New() - r.GET("/", s.HelloWorldHandler) - // Create a test HTTP request + r.GET("/", h.HelloWorldHandler) + req, err := http.NewRequest("GET", "/", nil) if err != nil { t.Fatal(err) } - // Create a ResponseRecorder to record the response + rr := httptest.NewRecorder() - // Serve the HTTP request r.ServeHTTP(rr, req) - // Check the status code + if status := rr.Code; status != http.StatusOK { - t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusOK) + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) } - // Check the response body - expected := "{\"message\":\"Hello World\"}" + + expected := `{"message":"Hello World"}` if rr.Body.String() != expected { - t.Errorf("Handler returned unexpected body: got %v want %v", rr.Body.String(), expected) + t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) } } diff --git a/backend/internal/handler/routes.go b/backend/internal/handler/routes.go new file mode 100644 index 0000000..048f12d --- /dev/null +++ b/backend/internal/handler/routes.go @@ -0,0 +1,25 @@ +package handler + +import ( + "net/http" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +// RegisterRoutes creates the Gin engine, applies middleware, and registers all routes. +func (h *Handler) RegisterRoutes() http.Handler { + r := gin.Default() + + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"http://localhost:3000"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, + AllowHeaders: []string{"Accept", "Authorization", "Content-Type"}, + AllowCredentials: true, + })) + + r.GET("/", h.HelloWorldHandler) + r.GET("/health", h.HealthHandler) + + return r +} diff --git a/backend/internal/repository/postgres/db.go b/backend/internal/repository/postgres/db.go new file mode 100644 index 0000000..644c5fd --- /dev/null +++ b/backend/internal/repository/postgres/db.go @@ -0,0 +1,33 @@ +package postgres + +import ( + "database/sql" + "fmt" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +// DBConfig holds all parameters needed to open a PostgreSQL connection. +type DBConfig struct { + Host string + Port string + Database string + Username string + Password string + Schema string + SSLMode string +} + +// NewPostgresDB opens a *sql.DB connection using the provided config. +// The caller is responsible for calling db.Close() on shutdown. +func NewPostgresDB(cfg DBConfig) (*sql.DB, error) { + connStr := fmt.Sprintf( + "postgres://%s:%s@%s:%s/%s?sslmode=%s&search_path=%s", + cfg.Username, cfg.Password, cfg.Host, cfg.Port, cfg.Database, cfg.SSLMode, cfg.Schema, + ) + db, err := sql.Open("pgx", connStr) + if err != nil { + return nil, fmt.Errorf("postgres: open: %w", err) + } + return db, nil +} diff --git a/backend/internal/repository/postgres/health_repository.go b/backend/internal/repository/postgres/health_repository.go new file mode 100644 index 0000000..294c0f8 --- /dev/null +++ b/backend/internal/repository/postgres/health_repository.go @@ -0,0 +1,46 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + "time" + + "backend/internal/domain" +) + +// HealthRepository checks the health of the postgres connection. +type HealthRepository struct { + db *sql.DB +} + +// NewHealthRepository constructs a HealthRepository. +func NewHealthRepository(db *sql.DB) *HealthRepository { + return &HealthRepository{db: db} +} + +// Health pings the database and returns raw connection pool statistics. +// Returns stats with status "down" and a non-nil error when the ping fails. +func (r *HealthRepository) Health(ctx context.Context) (domain.HealthStats, error) { + pingCtx, cancel := context.WithTimeout(ctx, 1*time.Second) + defer cancel() + + if err := r.db.PingContext(pingCtx); err != nil { + return domain.HealthStats{ + Status: "down", + Error: fmt.Sprintf("db down: %v", err), + }, fmt.Errorf("postgres: health ping: %w", err) + } + + dbStats := r.db.Stats() + return domain.HealthStats{ + Status: "up", + OpenConnections: dbStats.OpenConnections, + InUse: dbStats.InUse, + Idle: dbStats.Idle, + WaitCount: dbStats.WaitCount, + WaitDuration: dbStats.WaitDuration.String(), + MaxIdleClosed: dbStats.MaxIdleClosed, + MaxLifetimeClosed: dbStats.MaxLifetimeClosed, + }, nil +} diff --git a/backend/internal/repository/postgres/health_repository_test.go b/backend/internal/repository/postgres/health_repository_test.go new file mode 100644 index 0000000..8d1c721 --- /dev/null +++ b/backend/internal/repository/postgres/health_repository_test.go @@ -0,0 +1,110 @@ +package postgres + +import ( + "context" + "database/sql" + "log" + "testing" + "time" + + "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" +) + +var testDB *sql.DB + +func mustStartPostgresContainer() (func(context.Context, ...testcontainers.TerminateOption) error, error) { + var ( + dbName = "database" + dbPwd = "password" + dbUser = "user" + ) + + container, err := tcpostgres.Run( + context.Background(), + "postgres:latest", + tcpostgres.WithDatabase(dbName), + tcpostgres.WithUsername(dbUser), + tcpostgres.WithPassword(dbPwd), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(5*time.Second), + ), + ) + if err != nil { + return nil, err + } + + dbHost, err := container.Host(context.Background()) + if err != nil { + return container.Terminate, err + } + + dbPort, err := container.MappedPort(context.Background(), "5432/tcp") + if err != nil { + return container.Terminate, err + } + + cfg := DBConfig{ + Host: dbHost, + Port: dbPort.Port(), + Database: dbName, + Username: dbUser, + Password: dbPwd, + Schema: "public", + SSLMode: "disable", + } + + db, err := NewPostgresDB(cfg) + if err != nil { + return container.Terminate, err + } + testDB = db + + return container.Terminate, nil +} + +func TestMain(m *testing.M) { + teardown, err := mustStartPostgresContainer() + if err != nil { + log.Fatalf("could not start postgres container: %v", err) + } + + m.Run() + + if teardown != nil && teardown(context.Background()) != nil { + log.Fatalf("could not teardown postgres container: %v", err) + } +} + +// Tests are ordered: TestNew → TestHealth → TestClose. +// TestClose closes testDB; tests after it would fail. + +func TestNew(t *testing.T) { + if testDB == nil { + t.Fatal("NewPostgresDB() returned nil db") + } +} + +func TestHealth(t *testing.T) { + repo := NewHealthRepository(testDB) + + stats, err := repo.Health(context.Background()) + if err != nil { + t.Fatalf("Health() returned unexpected error: %v", err) + } + if stats.Status != "up" { + t.Fatalf("expected status to be up, got %s", stats.Status) + } + if stats.Error != "" { + t.Fatalf("expected no error, got %s", stats.Error) + } +} + +func TestClose(t *testing.T) { + if err := testDB.Close(); err != nil { + t.Fatalf("expected Close() to return nil, got %v", err) + } +} diff --git a/backend/internal/server/routes.go b/backend/internal/server/routes.go deleted file mode 100644 index e33ae1b..0000000 --- a/backend/internal/server/routes.go +++ /dev/null @@ -1,36 +0,0 @@ -package server - -import ( - "net/http" - - "github.com/gin-contrib/cors" - "github.com/gin-gonic/gin" -) - -func (s *Server) RegisterRoutes() http.Handler { - r := gin.Default() - - r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"http://localhost:3000"}, // Add your web app URL - AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, - AllowHeaders: []string{"Accept", "Authorization", "Content-Type"}, - AllowCredentials: true, // Enable cookies/auth - })) - - r.GET("/", s.HelloWorldHandler) - - r.GET("/health", s.healthHandler) - - return r -} - -func (s *Server) HelloWorldHandler(c *gin.Context) { - resp := make(map[string]string) - resp["message"] = "Hello World" - - c.JSON(http.StatusOK, resp) -} - -func (s *Server) healthHandler(c *gin.Context) { - c.JSON(http.StatusOK, s.db.Health()) -} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 22363fc..538f5c7 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -9,31 +9,41 @@ import ( _ "github.com/joho/godotenv/autoload" - "backend/internal/database" + "backend/internal/handler" + "backend/internal/repository/postgres" + "backend/internal/usecase" ) -type Server struct { - port int - - db database.Service -} - -func NewServer() *http.Server { +// NewServer wires all layers and returns a configured *http.Server. +func NewServer() (*http.Server, error) { port, _ := strconv.Atoi(os.Getenv("PORT")) - NewServer := &Server{ - port: port, - db: database.New(), + cfg := postgres.DBConfig{ + Host: os.Getenv("BLUEPRINT_DB_HOST"), + Port: os.Getenv("BLUEPRINT_DB_PORT"), + Database: os.Getenv("BLUEPRINT_DB_DATABASE"), + Username: os.Getenv("BLUEPRINT_DB_USERNAME"), + Password: os.Getenv("BLUEPRINT_DB_PASSWORD"), + Schema: os.Getenv("BLUEPRINT_DB_SCHEMA"), + SSLMode: os.Getenv("BLUEPRINT_DB_SSLMODE"), } - // Declare Server config - server := &http.Server{ - Addr: fmt.Sprintf(":%d", NewServer.port), - Handler: NewServer.RegisterRoutes(), + db, err := postgres.NewPostgresDB(cfg) + if err != nil { + return nil, fmt.Errorf("server: database: %w", err) + } + + healthRepo := postgres.NewHealthRepository(db) + healthUC := usecase.NewHealthUseCase(healthRepo) + h := handler.NewHandler(healthUC) + + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: h.RegisterRoutes(), IdleTimeout: time.Minute, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, } - return server + return srv, nil } diff --git a/backend/internal/usecase/health_usecase.go b/backend/internal/usecase/health_usecase.go new file mode 100644 index 0000000..8440f83 --- /dev/null +++ b/backend/internal/usecase/health_usecase.go @@ -0,0 +1,49 @@ +package usecase + +import ( + "context" + + "backend/internal/domain" +) + +// HealthReader is the repository interface the use case depends on. +type HealthReader interface { + Health(ctx context.Context) (domain.HealthStats, error) +} + +// HealthUseCase is the application-layer interface consumed by handlers. +type HealthUseCase interface { + GetHealth(ctx context.Context) (domain.HealthStats, error) +} + +type healthUseCase struct { + repo HealthReader +} + +// NewHealthUseCase constructs the use case with its repository dependency. +func NewHealthUseCase(repo HealthReader) HealthUseCase { + return &healthUseCase{repo: repo} +} + +func (uc *healthUseCase) GetHealth(ctx context.Context) (domain.HealthStats, error) { + stats, err := uc.repo.Health(ctx) + if err != nil { + return stats, err + } + + stats.Message = "It's healthy" + if stats.OpenConnections > 40 { + stats.Message = "The database is experiencing heavy load." + } + if stats.WaitCount > 1000 { + stats.Message = "The database has a high number of wait events, indicating potential bottlenecks." + } + if stats.MaxIdleClosed > int64(stats.OpenConnections)/2 { + stats.Message = "Many idle connections are being closed, consider revising the connection pool settings." + } + if stats.MaxLifetimeClosed > int64(stats.OpenConnections)/2 { + stats.Message = "Many connections are being closed due to max lifetime, consider increasing max lifetime or revising the connection usage pattern." + } + + return stats, nil +} diff --git a/web/app/page.tsx b/web/app/page.tsx index 3f36f7c..75b06a3 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -36,7 +36,7 @@ export default function Home() {