A community based topic aggregation platform built on atproto

Add complete user management system

Implements full-stack user functionality following layered architecture:
- User domain models and validation
- RESTful API endpoints (GET /users/{id}, POST /users)
- PostgreSQL database with migrations and indexes
- Repository pattern with interfaces
- Service layer with business logic
- Integration tests and error handling
- Docker Compose for local development

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

bretton.dev 3454d1a5

+8
.idea/.gitignore
···
···
+
# Default ignored files
+
/shelf/
+
/workspace.xml
+
# Editor-based HTTP Client requests
+
/httpRequests/
+
# Datasource local storage ignored files
+
/dataSources/
+
/dataSources.local.xml
+9
.idea/Coves.iml
···
···
+
<?xml version="1.0" encoding="UTF-8"?>
+
<module type="WEB_MODULE" version="4">
+
<component name="Go" enabled="true" />
+
<component name="NewModuleRootManager">
+
<content url="file://$MODULE_DIR$" />
+
<orderEntry type="inheritedJdk" />
+
<orderEntry type="sourceFolder" forTests="false" />
+
</component>
+
</module>
+17
.idea/dataSources.xml
···
···
+
<?xml version="1.0" encoding="UTF-8"?>
+
<project version="4">
+
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
+
<data-source source="LOCAL" name="postgres@localhost" uuid="66e25b60-2901-4e78-bdb8-343f2c71fb79">
+
<driver-ref>postgresql</driver-ref>
+
<synchronize>true</synchronize>
+
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
+
<jdbc-url>jdbc:postgresql://localhost:5433/postgres</jdbc-url>
+
<jdbc-additional-properties>
+
<property name="com.intellij.clouds.kubernetes.db.host.port" />
+
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
+
<property name="com.intellij.clouds.kubernetes.db.container.port" />
+
</jdbc-additional-properties>
+
<working-dir>$ProjectFileDir$</working-dir>
+
</data-source>
+
</component>
+
</project>
+8
.idea/modules.xml
···
···
+
<?xml version="1.0" encoding="UTF-8"?>
+
<project version="4">
+
<component name="ProjectModuleManager">
+
<modules>
+
<module fileurl="file://$PROJECT_DIR$/.idea/Coves.iml" filepath="$PROJECT_DIR$/.idea/Coves.iml" />
+
</modules>
+
</component>
+
</project>
+6
.idea/vcs.xml
···
···
+
<?xml version="1.0" encoding="UTF-8"?>
+
<project version="4">
+
<component name="VcsDirectoryMappings">
+
<mapping directory="$PROJECT_DIR$" vcs="Git" />
+
</component>
+
</project>
+160
CLAUDE.md
···
···
+
Project:
+
You are a distinguished developer helping build Coves, a forum like atProto social media platform (think reddit).
+
+
Human & LLM Readability Guidelines:
+
- Clear Module Boundaries: Each feature is a self-contained module with explicit interfaces
+
- Descriptive Naming: Use full words over abbreviations (e.g., CommunityGovernance not CommGov)
+
- Structured Documentation: Each module includes purpose, dependencies, and example usage
+
- Consistent Patterns: RESTful APIs, standard error handling, predictable data structures
+
- Context-Rich Comments: Explain "why" not just "what" at decision points
+
+
Core Principles:
+
- When in doubt, choose the simpler implementation
+
- Features are the enemy of shipping
+
- A working tool today beats a perfect tool tomorrow
+
+
Utilize existing tech stack
+
- Before attempting to use an external tool, ensure it cannot be done via the current stack:
+
- Go Chi (Web framework)
+
- DB: PostgreSQL
+
- atProto for federation & user identities
+
+
# Architecture Guidelines
+
+
## Required Layered Architecture
+
Follow this strict separation of concerns:
+
```
+
Handler (HTTP) → Service (Business Logic) → Repository (Data Access) → Database
+
```
+
+
## Directory Structure
+
```
+
internal/
+
├── api/
+
│ ├── handlers/ # HTTP request/response handling ONLY
+
│ └── routes/ # Route definitions
+
├── core/
+
│ └── [domain]/ # Business logic, domain models, service interfaces
+
│ ├── service.go # Business logic implementation
+
│ ├── repository.go # Data access interface
+
│ └── [domain].go # Domain models
+
└── db/
+
└── postgres/ # Database implementation details
+
└── [domain]_repo.go # Repository implementations
+
```
+
+
## Strict Prohibitions
+
- **NEVER** put SQL queries in handlers
+
- **NEVER** import database packages in handlers
+
- **NEVER** pass *sql.DB directly to handlers
+
- **NEVER** mix business logic with HTTP concerns
+
- **NEVER** bypass the service layer
+
+
## Required Patterns
+
+
### Handlers (HTTP Layer)
+
- Only handle HTTP concerns: parsing requests, formatting responses
+
- Delegate all business logic to services
+
- No direct database access
+
+
Example:
+
```go
+
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
+
var req CreateUserRequest
+
json.NewDecoder(r.Body).Decode(&req)
+
+
user, err := h.userService.CreateUser(req) // Delegate to service
+
// Handle response formatting only
+
}
+
```
+
+
### Services (Business Layer)
+
- Contain all business logic and validation
+
- Use repository interfaces, never concrete implementations
+
- Handle transactions and complex operations
+
+
Example:
+
```go
+
type UserService struct {
+
userRepo UserRepository // Interface, not concrete type
+
}
+
```
+
+
### Repositories (Data Layer)
+
- Define interfaces in core/[domain]/
+
- Implement in db/postgres/
+
- Handle all SQL queries and database operations
+
+
Example:
+
```go
+
// Interface in core/users/repository.go
+
type UserRepository interface {
+
Create(user User) (*User, error)
+
GetByID(id int) (*User, error)
+
}
+
+
// Implementation in db/postgres/user_repo.go
+
type PostgresUserRepo struct {
+
db *sql.DB
+
}
+
```
+
+
## Testing Requirements
+
- Services must be easily mockable (use interfaces)
+
- Integration tests should test the full stack
+
- Unit tests should test individual layers in isolation
+
+
Test File Naming:
+
- Unit tests: `[file]_test.go` in same directory
+
- Integration tests: `[feature]_integration_test.go` in tests/ directory
+
+
## Claude Code Instructions
+
+
### Code Generation Patterns
+
When creating new features:
+
1. Generate interface first in core/[domain]/
+
2. Generate test file with failing tests
+
3. Generate implementation to pass tests
+
4. Generate handler with tests
+
5. Update routes in api/routes/
+
+
### Refactoring Checklist
+
Before considering a feature complete:
+
- All tests pass
+
- No SQL in handlers
+
- Services use interfaces only
+
- Error handling follows patterns
+
- API documented with examples
+
+
## Database Migrations
+
- Use golang-goose for version control
+
- Migrations in db/migrations/
+
- Never modify existing migrations
+
- Always provide rollback migrations
+
+
+
+
## Dependency Injection
+
- Use constructor functions for all components
+
- Pass interfaces, not concrete types
+
- Wire dependencies in main.go or cmd/server/main.go
+
+
Example dependency wiring:
+
```go
+
// main.go
+
userRepo := postgres.NewUserRepository(db)
+
userService := users.NewUserService(userRepo)
+
userHandler := handlers.NewUserHandler(userService)
+
```
+
+
## Error Handling
+
- Define custom error types in core/errors/
+
- Use error wrapping with context: fmt.Errorf("service: %w", err)
+
- Services return domain errors, handlers translate to HTTP status codes
+
- Never expose internal error details in API responses
+
+
### Context7 Usage Guidelines:
+
- Always check Context7 for best practices before implementing external integrations
+
- Use Context7 to understand proper error handling patterns for specific libraries
+
- Reference Context7 for testing patterns with external dependencies
+
- Consult Context7 for proper configuration patterns
+67
cmd/server/main.go
···
···
+
package main
+
+
import (
+
"database/sql"
+
"fmt"
+
"log"
+
"net/http"
+
"os"
+
+
"github.com/go-chi/chi/v5"
+
"github.com/go-chi/chi/v5/middleware"
+
_ "github.com/lib/pq"
+
"github.com/pressly/goose/v3"
+
+
"Coves/internal/api/routes"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
+
)
+
+
func main() {
+
dbURL := os.Getenv("DATABASE_URL")
+
if dbURL == "" {
+
dbURL = "postgres://postgres:password@localhost:5432/coves?sslmode=disable"
+
}
+
+
db, err := sql.Open("postgres", dbURL)
+
if err != nil {
+
log.Fatal("Failed to connect to database:", err)
+
}
+
defer db.Close()
+
+
if err := db.Ping(); err != nil {
+
log.Fatal("Failed to ping database:", err)
+
}
+
+
if err := goose.SetDialect("postgres"); err != nil {
+
log.Fatal("Failed to set goose dialect:", err)
+
}
+
+
if err := goose.Up(db, "internal/db/migrations"); err != nil {
+
log.Fatal("Failed to run migrations:", err)
+
}
+
+
r := chi.NewRouter()
+
+
r.Use(middleware.Logger)
+
r.Use(middleware.Recoverer)
+
r.Use(middleware.RequestID)
+
+
userRepo := postgres.NewUserRepository(db)
+
userService := users.NewUserService(userRepo)
+
+
r.Mount("/api/users", routes.UserRoutes(userService))
+
+
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
+
w.WriteHeader(http.StatusOK)
+
w.Write([]byte("OK"))
+
})
+
+
port := os.Getenv("PORT")
+
if port == "" {
+
port = "8080"
+
}
+
+
fmt.Printf("Server starting on port %s\n", port)
+
log.Fatal(http.ListenAndServe(":"+port, r))
+
}
+16
go.mod
···
···
+
module Coves
+
+
go 1.24
+
+
require (
+
github.com/go-chi/chi/v5 v5.2.1
+
github.com/lib/pq v1.10.9
+
github.com/pressly/goose/v3 v3.22.1
+
)
+
+
require (
+
github.com/mfridman/interpolate v0.0.2 // indirect
+
github.com/sethvargo/go-retry v0.3.0 // indirect
+
go.uber.org/multierr v1.11.0 // indirect
+
golang.org/x/sync v0.8.0 // indirect
+
)
+48
go.sum
···
···
+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
+
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
+
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
+
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+
github.com/pressly/goose/v3 v3.22.1 h1:2zICEfr1O3yTP9BRZMGPj7qFxQ+ik6yeo+z1LMuioLc=
+
github.com/pressly/goose/v3 v3.22.1/go.mod h1:xtMpbstWyCpyH+0cxLTMCENWBG+0CSxvTsXhW95d5eo=
+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
+
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
+
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
+
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
+
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
+
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
+
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
+
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
+
modernc.org/sqlite v1.33.0 h1:WWkA/T2G17okiLGgKAj4/RMIvgyMT19yQ038160IeYk=
+
modernc.org/sqlite v1.33.0/go.mod h1:9uQ9hF/pCZoYZK73D/ud5Z7cIRIILSZI8NdIemVMTX8=
+
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
+
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
+
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+71
internal/api/handlers/users.go
···
···
+
package handlers
+
+
import (
+
"Coves/internal/core/users"
+
"encoding/json"
+
"net/http"
+
"strconv"
+
"strings"
+
+
"github.com/go-chi/chi/v5"
+
)
+
+
type UserHandler struct {
+
userService users.UserServiceInterface
+
}
+
+
func NewUserHandler(userService users.UserServiceInterface) *UserHandler {
+
return &UserHandler{userService: userService}
+
}
+
+
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
+
idStr := chi.URLParam(r, "id")
+
id, err := strconv.Atoi(idStr)
+
if err != nil {
+
http.Error(w, "Invalid user ID", http.StatusBadRequest)
+
return
+
}
+
+
user, err := h.userService.GetUserByID(id)
+
if err != nil {
+
if strings.Contains(err.Error(), "not found") {
+
http.Error(w, "User not found", http.StatusNotFound)
+
return
+
}
+
if strings.Contains(err.Error(), "invalid") {
+
http.Error(w, err.Error(), http.StatusBadRequest)
+
return
+
}
+
http.Error(w, "Failed to fetch user", http.StatusInternalServerError)
+
return
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
json.NewEncoder(w).Encode(user)
+
}
+
+
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
+
var req users.CreateUserRequest
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+
http.Error(w, "Invalid request body", http.StatusBadRequest)
+
return
+
}
+
+
user, err := h.userService.CreateUser(req)
+
if err != nil {
+
if strings.Contains(err.Error(), "required") || strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "at least") {
+
http.Error(w, err.Error(), http.StatusBadRequest)
+
return
+
}
+
if strings.Contains(err.Error(), "already exists") {
+
http.Error(w, err.Error(), http.StatusConflict)
+
return
+
}
+
http.Error(w, "Failed to create user", http.StatusInternalServerError)
+
return
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusCreated)
+
json.NewEncoder(w).Encode(user)
+
}
+133
internal/api/handlers/users_test.go
···
···
+
package handlers_test
+
+
import (
+
"bytes"
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"net/http/httptest"
+
"testing"
+
+
"Coves/internal/api/handlers"
+
"Coves/internal/core/users"
+
)
+
+
type mockUserService struct {
+
users []users.User
+
shouldFail bool
+
}
+
+
func (m *mockUserService) CreateUser(req users.CreateUserRequest) (*users.User, error) {
+
if m.shouldFail {
+
return nil, fmt.Errorf("service: failed to create user")
+
}
+
+
user := &users.User{
+
ID: 1,
+
Email: req.Email,
+
Username: req.Username,
+
}
+
m.users = append(m.users, *user)
+
return user, nil
+
}
+
+
func (m *mockUserService) GetUserByID(id int) (*users.User, error) {
+
if m.shouldFail {
+
return nil, fmt.Errorf("service: failed to get user")
+
}
+
+
for _, user := range m.users {
+
if user.ID == id {
+
return &user, nil
+
}
+
}
+
return nil, fmt.Errorf("service: user not found")
+
}
+
+
func (m *mockUserService) GetUserByEmail(email string) (*users.User, error) {
+
for i := range m.users {
+
if m.users[i].Email == email {
+
return &m.users[i], nil
+
}
+
}
+
return nil, fmt.Errorf("service: user not found")
+
}
+
+
func (m *mockUserService) GetUserByUsername(username string) (*users.User, error) {
+
for i := range m.users {
+
if m.users[i].Username == username {
+
return &m.users[i], nil
+
}
+
}
+
return nil, fmt.Errorf("service: user not found")
+
}
+
+
func (m *mockUserService) UpdateUser(id int, req users.UpdateUserRequest) (*users.User, error) {
+
for i := range m.users {
+
if m.users[i].ID == id {
+
if req.Email != "" {
+
m.users[i].Email = req.Email
+
}
+
if req.Username != "" {
+
m.users[i].Username = req.Username
+
}
+
return &m.users[i], nil
+
}
+
}
+
return nil, fmt.Errorf("service: user not found")
+
}
+
+
func (m *mockUserService) DeleteUser(id int) error {
+
for i, user := range m.users {
+
if user.ID == id {
+
m.users = append(m.users[:i], m.users[i+1:]...)
+
return nil
+
}
+
}
+
return fmt.Errorf("service: user not found")
+
}
+
+
func TestCreateUserHandler(t *testing.T) {
+
service := &mockUserService{}
+
handler := handlers.NewUserHandler(service)
+
+
tests := []struct {
+
name string
+
body users.CreateUserRequest
+
wantStatus int
+
}{
+
{
+
name: "valid request",
+
body: users.CreateUserRequest{
+
Email: "test@example.com",
+
Username: "testuser",
+
},
+
wantStatus: http.StatusCreated,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
body, _ := json.Marshal(tt.body)
+
req := httptest.NewRequest("POST", "/api/users", bytes.NewReader(body))
+
req.Header.Set("Content-Type", "application/json")
+
+
rr := httptest.NewRecorder()
+
handler.CreateUser(rr, req)
+
+
if status := rr.Code; status != tt.wantStatus {
+
t.Errorf("handler returned wrong status code: got %v want %v", status, tt.wantStatus)
+
}
+
+
if tt.wantStatus == http.StatusCreated {
+
var user users.User
+
if err := json.NewDecoder(rr.Body).Decode(&user); err != nil {
+
t.Errorf("failed to decode response: %v", err)
+
}
+
if user.Email != tt.body.Email {
+
t.Errorf("expected email %s but got %s", tt.body.Email, user.Email)
+
}
+
}
+
})
+
}
+
}
+18
internal/api/routes/users.go
···
···
+
package routes
+
+
import (
+
"github.com/go-chi/chi/v5"
+
+
"Coves/internal/api/handlers"
+
"Coves/internal/core/users"
+
)
+
+
func UserRoutes(userService users.UserServiceInterface) chi.Router {
+
r := chi.NewRouter()
+
userHandler := handlers.NewUserHandler(userService)
+
+
r.Post("/", userHandler.CreateUser)
+
r.Get("/{id}", userHandler.GetUser)
+
+
return r
+
}
+67
internal/core/errors/errors.go
···
···
+
package errors
+
+
import (
+
"errors"
+
"fmt"
+
)
+
+
var (
+
ErrNotFound = errors.New("resource not found")
+
ErrAlreadyExists = errors.New("resource already exists")
+
ErrInvalidInput = errors.New("invalid input")
+
ErrUnauthorized = errors.New("unauthorized")
+
ErrForbidden = errors.New("forbidden")
+
ErrInternal = errors.New("internal server error")
+
ErrDatabaseError = errors.New("database error")
+
ErrValidationFailed = errors.New("validation failed")
+
)
+
+
type ValidationError struct {
+
Field string
+
Message string
+
}
+
+
func (e ValidationError) Error() string {
+
return fmt.Sprintf("validation error on field '%s': %s", e.Field, e.Message)
+
}
+
+
type ConflictError struct {
+
Resource string
+
Field string
+
Value string
+
}
+
+
func (e ConflictError) Error() string {
+
return fmt.Sprintf("%s with %s '%s' already exists", e.Resource, e.Field, e.Value)
+
}
+
+
type NotFoundError struct {
+
Resource string
+
ID interface{}
+
}
+
+
func (e NotFoundError) Error() string {
+
return fmt.Sprintf("%s with ID '%v' not found", e.Resource, e.ID)
+
}
+
+
func NewValidationError(field, message string) error {
+
return ValidationError{
+
Field: field,
+
Message: message,
+
}
+
}
+
+
func NewConflictError(resource, field, value string) error {
+
return ConflictError{
+
Resource: resource,
+
Field: field,
+
Value: value,
+
}
+
}
+
+
func NewNotFoundError(resource string, id interface{}) error {
+
return NotFoundError{
+
Resource: resource,
+
ID: id,
+
}
+
}
+10
internal/core/users/interfaces.go
···
···
+
package users
+
+
type UserServiceInterface interface {
+
CreateUser(req CreateUserRequest) (*User, error)
+
GetUserByID(id int) (*User, error)
+
GetUserByEmail(email string) (*User, error)
+
GetUserByUsername(username string) (*User, error)
+
UpdateUser(id int, req UpdateUserRequest) (*User, error)
+
DeleteUser(id int) error
+
}
+10
internal/core/users/repository.go
···
···
+
package users
+
+
type UserRepository interface {
+
Create(user *User) (*User, error)
+
GetByID(id int) (*User, error)
+
GetByEmail(email string) (*User, error)
+
GetByUsername(username string) (*User, error)
+
Update(user *User) (*User, error)
+
Delete(id int) error
+
}
+164
internal/core/users/service.go
···
···
+
package users
+
+
import (
+
"fmt"
+
"strings"
+
)
+
+
type UserService struct {
+
userRepo UserRepository
+
}
+
+
func NewUserService(userRepo UserRepository) *UserService {
+
return &UserService{
+
userRepo: userRepo,
+
}
+
}
+
+
func (s *UserService) CreateUser(req CreateUserRequest) (*User, error) {
+
if err := s.validateCreateRequest(req); err != nil {
+
return nil, err
+
}
+
+
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
+
req.Username = strings.TrimSpace(req.Username)
+
+
existingUser, _ := s.userRepo.GetByEmail(req.Email)
+
if existingUser != nil {
+
return nil, fmt.Errorf("service: email already exists")
+
}
+
+
existingUser, _ = s.userRepo.GetByUsername(req.Username)
+
if existingUser != nil {
+
return nil, fmt.Errorf("service: username already exists")
+
}
+
+
user := &User{
+
Email: req.Email,
+
Username: req.Username,
+
}
+
+
return s.userRepo.Create(user)
+
}
+
+
func (s *UserService) GetUserByID(id int) (*User, error) {
+
if id <= 0 {
+
return nil, fmt.Errorf("service: invalid user ID")
+
}
+
+
user, err := s.userRepo.GetByID(id)
+
if err != nil {
+
if strings.Contains(err.Error(), "not found") {
+
return nil, fmt.Errorf("service: user not found")
+
}
+
return nil, fmt.Errorf("service: %w", err)
+
}
+
+
return user, nil
+
}
+
+
func (s *UserService) GetUserByEmail(email string) (*User, error) {
+
email = strings.TrimSpace(strings.ToLower(email))
+
if email == "" {
+
return nil, fmt.Errorf("service: email is required")
+
}
+
+
user, err := s.userRepo.GetByEmail(email)
+
if err != nil {
+
if strings.Contains(err.Error(), "not found") {
+
return nil, fmt.Errorf("service: user not found")
+
}
+
return nil, fmt.Errorf("service: %w", err)
+
}
+
+
return user, nil
+
}
+
+
func (s *UserService) GetUserByUsername(username string) (*User, error) {
+
username = strings.TrimSpace(username)
+
if username == "" {
+
return nil, fmt.Errorf("service: username is required")
+
}
+
+
user, err := s.userRepo.GetByUsername(username)
+
if err != nil {
+
if strings.Contains(err.Error(), "not found") {
+
return nil, fmt.Errorf("service: user not found")
+
}
+
return nil, fmt.Errorf("service: %w", err)
+
}
+
+
return user, nil
+
}
+
+
func (s *UserService) UpdateUser(id int, req UpdateUserRequest) (*User, error) {
+
user, err := s.GetUserByID(id)
+
if err != nil {
+
return nil, err
+
}
+
+
if req.Email != "" {
+
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
+
if req.Email != user.Email {
+
existingUser, _ := s.userRepo.GetByEmail(req.Email)
+
if existingUser != nil && existingUser.ID != id {
+
return nil, fmt.Errorf("service: email already exists")
+
}
+
}
+
user.Email = req.Email
+
}
+
+
if req.Username != "" {
+
req.Username = strings.TrimSpace(req.Username)
+
if req.Username != user.Username {
+
existingUser, _ := s.userRepo.GetByUsername(req.Username)
+
if existingUser != nil && existingUser.ID != id {
+
return nil, fmt.Errorf("service: username already exists")
+
}
+
}
+
user.Username = req.Username
+
}
+
+
return s.userRepo.Update(user)
+
}
+
+
func (s *UserService) DeleteUser(id int) error {
+
if id <= 0 {
+
return fmt.Errorf("service: invalid user ID")
+
}
+
+
err := s.userRepo.Delete(id)
+
if err != nil {
+
if strings.Contains(err.Error(), "not found") {
+
return fmt.Errorf("service: user not found")
+
}
+
return fmt.Errorf("service: %w", err)
+
}
+
+
return nil
+
}
+
+
func (s *UserService) validateCreateRequest(req CreateUserRequest) error {
+
if strings.TrimSpace(req.Email) == "" {
+
return fmt.Errorf("service: email is required")
+
}
+
+
if strings.TrimSpace(req.Username) == "" {
+
return fmt.Errorf("service: username is required")
+
}
+
+
if !strings.Contains(req.Email, "@") {
+
return fmt.Errorf("service: invalid email format")
+
}
+
+
if len(req.Username) < 3 {
+
return fmt.Errorf("service: username must be at least 3 characters")
+
}
+
+
return nil
+
}
+
+
type UpdateUserRequest struct {
+
Email string `json:"email,omitempty"`
+
Username string `json:"username,omitempty"`
+
}
+272
internal/core/users/service_test.go
···
···
+
package users_test
+
+
import (
+
"fmt"
+
"testing"
+
"time"
+
+
"Coves/internal/core/users"
+
)
+
+
type mockUserRepository struct {
+
users map[int]*users.User
+
nextID int
+
shouldFail bool
+
}
+
+
func newMockUserRepository() *mockUserRepository {
+
return &mockUserRepository{
+
users: make(map[int]*users.User),
+
nextID: 1,
+
}
+
}
+
+
func (m *mockUserRepository) Create(user *users.User) (*users.User, error) {
+
if m.shouldFail {
+
return nil, fmt.Errorf("mock: database error")
+
}
+
+
user.ID = m.nextID
+
m.nextID++
+
user.CreatedAt = time.Now()
+
user.UpdatedAt = time.Now()
+
+
m.users[user.ID] = user
+
return user, nil
+
}
+
+
func (m *mockUserRepository) GetByID(id int) (*users.User, error) {
+
if m.shouldFail {
+
return nil, fmt.Errorf("mock: database error")
+
}
+
+
user, exists := m.users[id]
+
if !exists {
+
return nil, fmt.Errorf("repository: user not found")
+
}
+
return user, nil
+
}
+
+
func (m *mockUserRepository) GetByEmail(email string) (*users.User, error) {
+
if m.shouldFail {
+
return nil, fmt.Errorf("mock: database error")
+
}
+
+
for _, user := range m.users {
+
if user.Email == email {
+
return user, nil
+
}
+
}
+
return nil, fmt.Errorf("repository: user not found")
+
}
+
+
func (m *mockUserRepository) GetByUsername(username string) (*users.User, error) {
+
if m.shouldFail {
+
return nil, fmt.Errorf("mock: database error")
+
}
+
+
for _, user := range m.users {
+
if user.Username == username {
+
return user, nil
+
}
+
}
+
return nil, fmt.Errorf("repository: user not found")
+
}
+
+
func (m *mockUserRepository) Update(user *users.User) (*users.User, error) {
+
if m.shouldFail {
+
return nil, fmt.Errorf("mock: database error")
+
}
+
+
if _, exists := m.users[user.ID]; !exists {
+
return nil, fmt.Errorf("repository: user not found")
+
}
+
+
user.UpdatedAt = time.Now()
+
m.users[user.ID] = user
+
return user, nil
+
}
+
+
func (m *mockUserRepository) Delete(id int) error {
+
if m.shouldFail {
+
return fmt.Errorf("mock: database error")
+
}
+
+
if _, exists := m.users[id]; !exists {
+
return fmt.Errorf("repository: user not found")
+
}
+
+
delete(m.users, id)
+
return nil
+
}
+
+
func TestCreateUser(t *testing.T) {
+
repo := newMockUserRepository()
+
service := users.NewUserService(repo)
+
+
tests := []struct {
+
name string
+
req users.CreateUserRequest
+
wantErr bool
+
errMsg string
+
}{
+
{
+
name: "valid user",
+
req: users.CreateUserRequest{
+
Email: "test@example.com",
+
Username: "testuser",
+
},
+
wantErr: false,
+
},
+
{
+
name: "empty email",
+
req: users.CreateUserRequest{
+
Email: "",
+
Username: "testuser",
+
},
+
wantErr: true,
+
errMsg: "email is required",
+
},
+
{
+
name: "empty username",
+
req: users.CreateUserRequest{
+
Email: "test@example.com",
+
Username: "",
+
},
+
wantErr: true,
+
errMsg: "username is required",
+
},
+
{
+
name: "invalid email format",
+
req: users.CreateUserRequest{
+
Email: "invalidemail",
+
Username: "testuser",
+
},
+
wantErr: true,
+
errMsg: "invalid email format",
+
},
+
{
+
name: "short username",
+
req: users.CreateUserRequest{
+
Email: "test@example.com",
+
Username: "ab",
+
},
+
wantErr: true,
+
errMsg: "username must be at least 3 characters",
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
user, err := service.CreateUser(tt.req)
+
+
if tt.wantErr {
+
if err == nil {
+
t.Errorf("expected error but got none")
+
} else if tt.errMsg != "" && err.Error() != "service: "+tt.errMsg {
+
t.Errorf("expected error message '%s' but got '%s'", tt.errMsg, err.Error())
+
}
+
} else {
+
if err != nil {
+
t.Errorf("unexpected error: %v", err)
+
}
+
if user == nil {
+
t.Errorf("expected user but got nil")
+
}
+
}
+
})
+
}
+
}
+
+
func TestCreateUserDuplicates(t *testing.T) {
+
repo := newMockUserRepository()
+
service := users.NewUserService(repo)
+
+
req := users.CreateUserRequest{
+
Email: "test@example.com",
+
Username: "testuser",
+
}
+
+
_, err := service.CreateUser(req)
+
if err != nil {
+
t.Fatalf("unexpected error creating first user: %v", err)
+
}
+
+
_, err = service.CreateUser(req)
+
if err == nil {
+
t.Errorf("expected error for duplicate email but got none")
+
} else if err.Error() != "service: email already exists" {
+
t.Errorf("unexpected error message: %v", err)
+
}
+
+
req2 := users.CreateUserRequest{
+
Email: "different@example.com",
+
Username: "testuser",
+
}
+
+
_, err = service.CreateUser(req2)
+
if err == nil {
+
t.Errorf("expected error for duplicate username but got none")
+
} else if err.Error() != "service: username already exists" {
+
t.Errorf("unexpected error message: %v", err)
+
}
+
}
+
+
func TestGetUserByID(t *testing.T) {
+
repo := newMockUserRepository()
+
service := users.NewUserService(repo)
+
+
createdUser, err := service.CreateUser(users.CreateUserRequest{
+
Email: "test@example.com",
+
Username: "testuser",
+
})
+
if err != nil {
+
t.Fatalf("failed to create user: %v", err)
+
}
+
+
tests := []struct {
+
name string
+
id int
+
wantErr bool
+
errMsg string
+
}{
+
{
+
name: "valid ID",
+
id: createdUser.ID,
+
wantErr: false,
+
},
+
{
+
name: "invalid ID",
+
id: 0,
+
wantErr: true,
+
errMsg: "invalid user ID",
+
},
+
{
+
name: "non-existent ID",
+
id: 999,
+
wantErr: true,
+
errMsg: "user not found",
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
user, err := service.GetUserByID(tt.id)
+
+
if tt.wantErr {
+
if err == nil {
+
t.Errorf("expected error but got none")
+
} else if tt.errMsg != "" && err.Error() != "service: "+tt.errMsg {
+
t.Errorf("expected error message '%s' but got '%s'", tt.errMsg, err.Error())
+
}
+
} else {
+
if err != nil {
+
t.Errorf("unexpected error: %v", err)
+
}
+
if user == nil {
+
t.Errorf("expected user but got nil")
+
}
+
}
+
})
+
}
+
}
+18
internal/core/users/user.go
···
···
+
package users
+
+
import (
+
"time"
+
)
+
+
type User struct {
+
ID int `json:"id" db:"id"`
+
Email string `json:"email" db:"email"`
+
Username string `json:"username" db:"username"`
+
CreatedAt time.Time `json:"created_at" db:"created_at"`
+
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
+
}
+
+
type CreateUserRequest struct {
+
Email string `json:"email"`
+
Username string `json:"username"`
+
}
+12
internal/db/local_dev_db_compose/docker-compose.yml
···
···
+
# docker-compose.yml
+
services:
+
postgres:
+
image: postgres:15
+
network_mode: host # Add this line
+
environment:
+
POSTGRES_DB: coves_dev
+
POSTGRES_USER: dev_user
+
POSTGRES_PASSWORD: dev_password
+
PGPORT: 5433
+
volumes:
+
- ~/Code/Coves/local_dev_data:/var/lib/postgresql/data
+14
internal/db/migrations/001_create_users_table.sql
···
···
+
-- +goose Up
+
CREATE TABLE users (
+
id SERIAL PRIMARY KEY,
+
email VARCHAR(255) UNIQUE NOT NULL,
+
username VARCHAR(50) UNIQUE NOT NULL,
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+
);
+
+
CREATE INDEX idx_users_email ON users(email);
+
CREATE INDEX idx_users_username ON users(username);
+
+
-- +goose Down
+
DROP TABLE users;
+123
internal/db/postgres/user_repo.go
···
···
+
package postgres
+
+
import (
+
"database/sql"
+
"fmt"
+
+
"Coves/internal/core/users"
+
)
+
+
type PostgresUserRepo struct {
+
db *sql.DB
+
}
+
+
func NewUserRepository(db *sql.DB) users.UserRepository {
+
return &PostgresUserRepo{db: db}
+
}
+
+
func (r *PostgresUserRepo) Create(user *users.User) (*users.User, error) {
+
query := `
+
INSERT INTO users (email, username)
+
VALUES ($1, $2)
+
RETURNING id, email, username, created_at, updated_at`
+
+
err := r.db.QueryRow(query, user.Email, user.Username).
+
Scan(&user.ID, &user.Email, &user.Username, &user.CreatedAt, &user.UpdatedAt)
+
+
if err != nil {
+
return nil, fmt.Errorf("repository: failed to create user: %w", err)
+
}
+
+
return user, nil
+
}
+
+
func (r *PostgresUserRepo) GetByID(id int) (*users.User, error) {
+
user := &users.User{}
+
query := `SELECT id, email, username, created_at, updated_at FROM users WHERE id = $1`
+
+
err := r.db.QueryRow(query, id).
+
Scan(&user.ID, &user.Email, &user.Username, &user.CreatedAt, &user.UpdatedAt)
+
+
if err == sql.ErrNoRows {
+
return nil, fmt.Errorf("repository: user not found")
+
}
+
if err != nil {
+
return nil, fmt.Errorf("repository: failed to get user: %w", err)
+
}
+
+
return user, nil
+
}
+
+
func (r *PostgresUserRepo) GetByEmail(email string) (*users.User, error) {
+
user := &users.User{}
+
query := `SELECT id, email, username, created_at, updated_at FROM users WHERE email = $1`
+
+
err := r.db.QueryRow(query, email).
+
Scan(&user.ID, &user.Email, &user.Username, &user.CreatedAt, &user.UpdatedAt)
+
+
if err == sql.ErrNoRows {
+
return nil, fmt.Errorf("repository: user not found")
+
}
+
if err != nil {
+
return nil, fmt.Errorf("repository: failed to get user by email: %w", err)
+
}
+
+
return user, nil
+
}
+
+
func (r *PostgresUserRepo) GetByUsername(username string) (*users.User, error) {
+
user := &users.User{}
+
query := `SELECT id, email, username, created_at, updated_at FROM users WHERE username = $1`
+
+
err := r.db.QueryRow(query, username).
+
Scan(&user.ID, &user.Email, &user.Username, &user.CreatedAt, &user.UpdatedAt)
+
+
if err == sql.ErrNoRows {
+
return nil, fmt.Errorf("repository: user not found")
+
}
+
if err != nil {
+
return nil, fmt.Errorf("repository: failed to get user by username: %w", err)
+
}
+
+
return user, nil
+
}
+
+
func (r *PostgresUserRepo) Update(user *users.User) (*users.User, error) {
+
query := `
+
UPDATE users
+
SET email = $2, username = $3, updated_at = CURRENT_TIMESTAMP
+
WHERE id = $1
+
RETURNING id, email, username, created_at, updated_at`
+
+
err := r.db.QueryRow(query, user.ID, user.Email, user.Username).
+
Scan(&user.ID, &user.Email, &user.Username, &user.CreatedAt, &user.UpdatedAt)
+
+
if err == sql.ErrNoRows {
+
return nil, fmt.Errorf("repository: user not found")
+
}
+
if err != nil {
+
return nil, fmt.Errorf("repository: failed to update user: %w", err)
+
}
+
+
return user, nil
+
}
+
+
func (r *PostgresUserRepo) Delete(id int) error {
+
query := `DELETE FROM users WHERE id = $1`
+
+
result, err := r.db.Exec(query, id)
+
if err != nil {
+
return fmt.Errorf("repository: failed to delete user: %w", err)
+
}
+
+
rowsAffected, err := result.RowsAffected()
+
if err != nil {
+
return fmt.Errorf("repository: failed to get rows affected: %w", err)
+
}
+
+
if rowsAffected == 0 {
+
return fmt.Errorf("repository: user not found")
+
}
+
+
return nil
+
}
server

This is a binary file and will not be displayed.

+94
tests/integration/integration_test.go
···
···
+
package integration
+
+
import (
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
+
"bytes"
+
"database/sql"
+
"encoding/json"
+
"net/http"
+
"net/http/httptest"
+
"os"
+
"testing"
+
+
"github.com/go-chi/chi/v5"
+
_ "github.com/lib/pq"
+
"github.com/pressly/goose/v3"
+
+
"Coves/internal/api/routes"
+
)
+
+
func setupTestDB(t *testing.T) *sql.DB {
+
dbURL := os.Getenv("TEST_DATABASE_URL")
+
if dbURL == "" {
+
dbURL = "postgres://dev_user:dev_password@localhost:5433/coves_dev?sslmode=disable"
+
}
+
+
db, err := sql.Open("postgres", dbURL)
+
if err != nil {
+
t.Fatalf("Failed to connect to test database: %v", err)
+
}
+
+
if err := db.Ping(); err != nil {
+
t.Fatalf("Failed to ping test database: %v", err)
+
}
+
+
if err := goose.SetDialect("postgres"); err != nil {
+
t.Fatalf("Failed to set goose dialect: %v", err)
+
}
+
+
if err := goose.Up(db, "../../internal/db/migrations"); err != nil {
+
t.Fatalf("Failed to run migrations: %v", err)
+
}
+
+
// Clean up any existing test data
+
_, err = db.Exec("DELETE FROM users WHERE email LIKE '%@example.com'")
+
if err != nil {
+
t.Logf("Warning: Failed to clean up test data: %v", err)
+
}
+
+
return db
+
}
+
+
func TestCreateUser(t *testing.T) {
+
db := setupTestDB(t)
+
defer db.Close()
+
+
// Wire up dependencies according to architecture
+
userRepo := postgres.NewUserRepository(db)
+
userService := users.NewUserService(userRepo)
+
+
r := chi.NewRouter()
+
r.Mount("/api/users", routes.UserRoutes(userService))
+
+
user := users.CreateUserRequest{
+
Email: "test@example.com",
+
Username: "testuser",
+
}
+
+
body, _ := json.Marshal(user)
+
req := httptest.NewRequest("POST", "/api/users", bytes.NewBuffer(body))
+
req.Header.Set("Content-Type", "application/json")
+
+
w := httptest.NewRecorder()
+
r.ServeHTTP(w, req)
+
+
if w.Code != http.StatusCreated {
+
t.Errorf("Expected status %d, got %d. Response: %s", http.StatusCreated, w.Code, w.Body.String())
+
return
+
}
+
+
var createdUser users.User
+
if err := json.NewDecoder(w.Body).Decode(&createdUser); err != nil {
+
t.Fatalf("Failed to decode response: %v", err)
+
}
+
+
if createdUser.Email != user.Email {
+
t.Errorf("Expected email %s, got %s", user.Email, createdUser.Email)
+
}
+
+
if createdUser.Username != user.Username {
+
t.Errorf("Expected username %s, got %s", user.Username, createdUser.Username)
+
}
+
}
+