build: /implement-feature user-preferences
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
rdev-worker 2026-02-08 06:13:10 +00:00
parent 3951ff5ed7
commit 73532902e7
23 changed files with 1218 additions and 1348 deletions

View File

@ -1,20 +1,36 @@
slug: user-preferences slug: user-preferences
title: User Preferences API title: User Preferences API
created: 2026-02-08T05:49:49.197429066Z created: 2026-02-08T05:49:49.197429066Z
phase: draft phase: implementation
phase_history: phase_history:
- phase: draft - phase: draft
entered: 2026-02-08T05:49:49.197429066Z entered: 2026-02-08T05:49:49.197429066Z
exited: 2026-02-08T06:04:08.481264669Z
- phase: specified
entered: 2026-02-08T06:04:08.481264669Z
exited: 2026-02-08T06:04:08.489768009Z
- phase: planned
entered: 2026-02-08T06:04:08.489768009Z
exited: 2026-02-08T06:04:08.49504538Z
- phase: ready
entered: 2026-02-08T06:04:08.49504538Z
exited: 2026-02-08T06:04:08.50042898Z
- phase: implementation
entered: 2026-02-08T06:04:08.50042898Z
artifacts: artifacts:
audit: audit:
status: pending status: pending
path: audit.md path: audit.md
design: design:
status: draft status: approved
path: design.md path: design.md
approved_by: user
approved_at: 2026-02-08T06:04:02.519059418Z
qa_plan: qa_plan:
status: draft status: approved
path: qa-plan.md path: qa-plan.md
approved_by: user
approved_at: 2026-02-08T06:04:02.531924677Z
qa_results: qa_results:
status: pending status: pending
path: qa-results.md path: qa-results.md
@ -22,34 +38,55 @@ artifacts:
status: pending status: pending
path: review.md path: review.md
spec: spec:
status: draft status: approved
path: spec.md path: spec.md
approved_by: user
approved_at: 2026-02-08T06:04:02.513348292Z
tasks: tasks:
status: draft status: approved
path: tasks.md path: tasks.md
approved_by: user
approved_at: 2026-02-08T06:04:02.525504967Z
total: 8 total: 8
completed: 8
tasks: tasks:
- id: task-001 - id: task-001
title: Domain layer - preference types, validation, defaults, and domain errors title: Domain layer - preference types, validation, defaults, and domain errors
status: pending status: complete
started_at: 2026-02-08T06:04:21.073131466Z
done_at: 2026-02-08T06:04:59.143838086Z
- id: task-002 - id: task-002
title: Port layer - PreferenceRepository interface and row type title: Port layer - PreferenceRepository interface and row type
status: pending status: complete
started_at: 2026-02-08T06:05:09.644910816Z
done_at: 2026-02-08T06:05:19.519459876Z
- id: task-003 - id: task-003
title: Service layer - PreferenceService with get, update, validation logic and unit tests title: Service layer - PreferenceService with get, update, validation logic and unit tests
status: pending status: complete
started_at: 2026-02-08T06:05:27.120395886Z
done_at: 2026-02-08T06:06:24.021112964Z
- id: task-004 - id: task-004
title: Database migration - create user_preferences table title: Database migration - create user_preferences table
status: pending status: complete
started_at: 2026-02-08T06:06:35.339731999Z
done_at: 2026-02-08T06:06:53.729592277Z
- id: task-005 - id: task-005
title: PostgreSQL adapter - implement PreferenceRepository with sqlx title: PostgreSQL adapter - implement PreferenceRepository with sqlx
status: pending status: complete
started_at: 2026-02-08T06:07:01.795533407Z
done_at: 2026-02-08T06:07:20.600911027Z
- id: task-006 - id: task-006
title: Handler layer - GET and PUT preference handlers with error mapping and handler tests title: Handler layer - GET and PUT preference handlers with error mapping and handler tests
status: pending status: complete
started_at: 2026-02-08T06:07:32.040998808Z
done_at: 2026-02-08T06:08:37.691446229Z
- id: task-007 - id: task-007
title: Routes, OpenAPI spec, and main.go wiring title: Routes, OpenAPI spec, and main.go wiring
status: pending status: complete
started_at: 2026-02-08T06:08:48.060805203Z
done_at: 2026-02-08T06:10:41.696522126Z
- id: task-008 - id: task-008
title: Cleanup - remove example scaffold files title: Cleanup - remove example scaffold files
status: pending status: complete
started_at: 2026-02-08T06:10:53.979045796Z
done_at: 2026-02-08T06:11:23.894960337Z

View File

@ -4,10 +4,10 @@ project:
active_work: active_work:
features: features:
- slug: user-preferences - slug: user-preferences
phase: draft phase: implementation
blocked: [] blocked: []
last_updated: 2026-02-08T05:49:49.197770758Z last_updated: 2026-02-08T06:11:23.89581271Z
last_action: CREATE_FEATURE last_action: COMPLETE_TASK
last_actor: cli last_actor: cli
history: history:
- timestamp: 2026-02-08T05:49:49.197770327Z - timestamp: 2026-02-08T05:49:49.197770327Z
@ -15,3 +15,83 @@ history:
feature: user-preferences feature: user-preferences
actor: cli actor: cli
result: success result: success
- timestamp: 2026-02-08T06:04:02.513963149Z
action: APPROVE_ARTIFACT
feature: user-preferences
actor: user
result: success
- timestamp: 2026-02-08T06:04:02.519799782Z
action: APPROVE_ARTIFACT
feature: user-preferences
actor: user
result: success
- timestamp: 2026-02-08T06:04:02.526201047Z
action: APPROVE_ARTIFACT
feature: user-preferences
actor: user
result: success
- timestamp: 2026-02-08T06:04:02.534360671Z
action: APPROVE_ARTIFACT
feature: user-preferences
actor: user
result: success
- timestamp: 2026-02-08T06:04:08.485187751Z
action: TRANSITION
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T06:04:08.490394658Z
action: TRANSITION
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T06:04:08.495775684Z
action: TRANSITION
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T06:04:08.501113688Z
action: TRANSITION
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T06:04:59.144474604Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T06:05:19.520050407Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T06:06:24.021723363Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T06:06:53.730244845Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T06:07:20.601540662Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T06:08:37.692266683Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T06:10:41.697480099Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T06:11:23.895812069Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success

View File

@ -1,15 +1,18 @@
// Package main is the entry point for the preferences-api service.
package main package main
import ( import (
"context"
"flag" "flag"
"fmt" "fmt"
"os" "os"
"git.threesix.ai/jordan/slack5-1770529463/pkg/app" "git.threesix.ai/jordan/slack5-1770529463/pkg/app"
"git.threesix.ai/jordan/slack5-1770529463/pkg/database"
"git.threesix.ai/jordan/slack5-1770529463/pkg/logging" "git.threesix.ai/jordan/slack5-1770529463/pkg/logging"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/adapter/memory" "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/adapter/postgres"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/api" "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/api"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/config"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/migrations"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/service" "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/service"
) )
@ -33,17 +36,39 @@ func main() {
// Create logger // Create logger
logger := logging.Default() logger := logging.Default()
// Load configuration
cfg := config.Load()
// Connect to database
ctx := context.Background()
pool := database.MustConnect(ctx, cfg.Database.URL, database.Options{
MaxOpenConns: cfg.Database.MaxOpenConns,
MaxIdleConns: cfg.Database.MaxIdleConns,
ConnMaxLifetime: cfg.Database.ConnMaxLifetime,
})
logger.Info("connected to database", "url", pool.URL)
// Run migrations
database.MustRunMigrations(ctx, pool, migrations.FS, migrations.Dir)
logger.Info("database migrations complete")
// Create adapters (repositories) // Create adapters (repositories)
exampleRepo := memory.NewExampleRepository() prefRepo := postgres.NewPreferenceRepository(pool.DB)
// Create services (business logic) // Create services (business logic)
exampleService := service.NewExampleService(exampleRepo, logger) preferenceService := service.NewPreferenceService(prefRepo, logger)
// Create application // Create application
application := app.New("preferences-api", app.WithDefaultPort(8001)) application := app.New("preferences-api", app.WithDefaultPort(8001))
// Register shutdown hook for database pool
application.OnShutdown(func(ctx context.Context) error {
logger.Info("closing database connection pool")
return pool.Close()
})
// Register routes with dependency injection // Register routes with dependency injection
api.RegisterRoutes(application, exampleService) api.RegisterRoutes(application, preferenceService)
// Start server // Start server
application.Run() application.Run()

View File

@ -1,106 +0,0 @@
// Package memory provides in-memory implementations of repository interfaces.
// Useful for development, testing, and prototyping.
package memory
import (
"context"
"sync"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port"
)
// Compile-time verification that ExampleRepository implements port.ExampleRepository.
var _ port.ExampleRepository = (*ExampleRepository)(nil)
// ExampleRepository is a thread-safe in-memory implementation of port.ExampleRepository.
type ExampleRepository struct {
mu sync.RWMutex
examples map[domain.ExampleID]*domain.Example
}
// NewExampleRepository creates a new in-memory example repository.
func NewExampleRepository() *ExampleRepository {
return &ExampleRepository{
examples: make(map[domain.ExampleID]*domain.Example),
}
}
// List returns all examples.
func (r *ExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]domain.Example, 0, len(r.examples))
for _, e := range r.examples {
result = append(result, *e)
}
return result, nil
}
// Get returns an example by ID.
// Returns domain.ErrExampleNotFound if not found.
func (r *ExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
r.mu.RLock()
defer r.mu.RUnlock()
e, ok := r.examples[id]
if !ok {
return nil, domain.ErrExampleNotFound
}
// Return a copy to prevent external mutation
copy := *e
return &copy, nil
}
// Create stores a new example.
func (r *ExampleRepository) Create(ctx context.Context, example *domain.Example) error {
r.mu.Lock()
defer r.mu.Unlock()
// Store a copy to prevent external mutation
copy := *example
r.examples[example.ID] = &copy
return nil
}
// Update modifies an existing example.
// Returns domain.ErrExampleNotFound if not found.
func (r *ExampleRepository) Update(ctx context.Context, example *domain.Example) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.examples[example.ID]; !ok {
return domain.ErrExampleNotFound
}
// Store a copy to prevent external mutation
copy := *example
r.examples[example.ID] = &copy
return nil
}
// Delete removes an example by ID.
// Returns domain.ErrExampleNotFound if not found.
func (r *ExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.examples[id]; !ok {
return domain.ErrExampleNotFound
}
delete(r.examples, id)
return nil
}
// ExistsByName checks if an example with the given name exists.
func (r *ExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, e := range r.examples {
if e.Name == name {
return true, nil
}
}
return false, nil
}

View File

@ -0,0 +1,68 @@
package postgres
import (
"context"
"time"
"github.com/jmoiron/sqlx"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port"
)
// Compile-time verification that PreferenceRepository implements port.PreferenceRepository.
var _ port.PreferenceRepository = (*PreferenceRepository)(nil)
// preferenceRow is the database representation of a preference row.
type preferenceRow struct {
UserID string `db:"user_id"`
Key string `db:"key"`
Value string `db:"value"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
// PreferenceRepository is a PostgreSQL implementation of port.PreferenceRepository.
type PreferenceRepository struct {
db *sqlx.DB
}
// NewPreferenceRepository creates a new PostgreSQL preference repository.
func NewPreferenceRepository(db *sqlx.DB) *PreferenceRepository {
return &PreferenceRepository{db: db}
}
// GetByUserID returns all stored preferences for a user.
func (r *PreferenceRepository) GetByUserID(ctx context.Context, userID string) ([]port.PreferenceRow, error) {
var rows []preferenceRow
err := r.db.SelectContext(ctx, &rows,
`SELECT user_id, key, value, created_at, updated_at FROM user_preferences WHERE user_id = $1`,
userID,
)
if err != nil {
return nil, err
}
result := make([]port.PreferenceRow, len(rows))
for i, row := range rows {
result[i] = port.PreferenceRow{
UserID: row.UserID,
Key: row.Key,
Value: row.Value,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
return result, nil
}
// Upsert creates or updates a single preference for a user.
func (r *PreferenceRepository) Upsert(ctx context.Context, userID string, key string, value string) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO user_preferences (user_id, key, value, created_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (user_id, key)
DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
userID, key, value,
)
return err
}

View File

@ -1,170 +0,0 @@
package handlers
import (
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"git.threesix.ai/jordan/slack5-1770529463/pkg/app"
"git.threesix.ai/jordan/slack5-1770529463/pkg/httperror"
"git.threesix.ai/jordan/slack5-1770529463/pkg/httpresponse"
"git.threesix.ai/jordan/slack5-1770529463/pkg/logging"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/service"
)
// Example handles HTTP requests for example resources.
type Example struct {
svc *service.ExampleService
logger *logging.Logger
}
// NewExample creates a new Example handler with injected dependencies.
func NewExample(svc *service.ExampleService, logger *logging.Logger) *Example {
return &Example{
svc: svc,
logger: logger.WithComponent("ExampleHandler"),
}
}
// CreateRequest is the request body for creating an example.
type CreateRequest struct {
Name string `json:"name" validate:"required,min=1,max=100"`
Description string `json:"description" validate:"max=500"`
}
// UpdateRequest is the request body for updating an example.
type UpdateRequest struct {
Name string `json:"name" validate:"required,min=1,max=100"`
Description string `json:"description" validate:"max=500"`
}
// ExampleResponse is the response for an example resource.
type ExampleResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// toResponse converts a domain example to an API response.
func toResponse(e *domain.Example) ExampleResponse {
return ExampleResponse{
ID: e.ID.String(),
Name: e.Name,
Description: e.Description,
CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: e.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// List returns all examples.
func (h *Example) List(w http.ResponseWriter, r *http.Request) error {
examples, err := h.svc.List(r.Context())
if err != nil {
return err
}
result := make([]ExampleResponse, len(examples))
for i, e := range examples {
result[i] = toResponse(&e)
}
httpresponse.OK(w, r, result)
return nil
}
// Get returns an example by ID.
func (h *Example) Get(w http.ResponseWriter, r *http.Request) error {
id := chi.URLParam(r, "id")
// Validate UUID format
if _, err := uuid.Parse(id); err != nil {
return httperror.BadRequest("invalid id format")
}
example, err := h.svc.Get(r.Context(), domain.ExampleID(id))
if err != nil {
return mapDomainError(err)
}
httpresponse.OK(w, r, toResponse(example))
return nil
}
// Create creates a new example.
func (h *Example) Create(w http.ResponseWriter, r *http.Request) error {
var req CreateRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
example, err := h.svc.Create(r.Context(), service.CreateInput{
Name: req.Name,
Description: req.Description,
})
if err != nil {
return mapDomainError(err)
}
httpresponse.Created(w, r, toResponse(example))
return nil
}
// Update updates an existing example.
func (h *Example) Update(w http.ResponseWriter, r *http.Request) error {
id := chi.URLParam(r, "id")
if _, err := uuid.Parse(id); err != nil {
return httperror.BadRequest("invalid id format")
}
var req UpdateRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
example, err := h.svc.Update(r.Context(), domain.ExampleID(id), service.UpdateInput{
Name: req.Name,
Description: req.Description,
})
if err != nil {
return mapDomainError(err)
}
httpresponse.OK(w, r, toResponse(example))
return nil
}
// Delete removes an example by ID.
func (h *Example) Delete(w http.ResponseWriter, r *http.Request) error {
id := chi.URLParam(r, "id")
if _, err := uuid.Parse(id); err != nil {
return httperror.BadRequest("invalid id format")
}
if err := h.svc.Delete(r.Context(), domain.ExampleID(id)); err != nil {
return mapDomainError(err)
}
httpresponse.NoContent(w)
return nil
}
// mapDomainError converts domain errors to HTTP errors.
func mapDomainError(err error) error {
switch {
case errors.Is(err, domain.ErrExampleNotFound):
return httperror.NotFound("example not found")
case errors.Is(err, domain.ErrDuplicateExample):
return httperror.Conflict("example with this name already exists")
case errors.Is(err, domain.ErrInvalidExampleName):
return httperror.BadRequest("invalid example name")
default:
return err
}
}

View File

@ -1,402 +0,0 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/go-chi/chi/v5"
"git.threesix.ai/jordan/slack5-1770529463/pkg/logging"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/service"
)
// mockExampleRepository implements port.ExampleRepository for testing.
type mockExampleRepository struct {
mu sync.RWMutex
examples map[domain.ExampleID]*domain.Example
}
var _ port.ExampleRepository = (*mockExampleRepository)(nil)
func newMockExampleRepository() *mockExampleRepository {
return &mockExampleRepository{
examples: make(map[domain.ExampleID]*domain.Example),
}
}
func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]domain.Example, 0, len(m.examples))
for _, e := range m.examples {
result = append(result, *e)
}
return result, nil
}
func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
m.mu.RLock()
defer m.mu.RUnlock()
e, ok := m.examples[id]
if !ok {
return nil, domain.ErrExampleNotFound
}
copy := *e
return &copy, nil
}
func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error {
m.mu.Lock()
defer m.mu.Unlock()
copy := *example
m.examples[example.ID] = &copy
return nil
}
func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.examples[example.ID]; !ok {
return domain.ErrExampleNotFound
}
copy := *example
m.examples[example.ID] = &copy
return nil
}
func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.examples[id]; !ok {
return domain.ErrExampleNotFound
}
delete(m.examples, id)
return nil
}
func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, e := range m.examples {
if e.Name == name {
return true, nil
}
}
return false, nil
}
func newTestHandler() (*Example, *mockExampleRepository) {
repo := newMockExampleRepository()
svc := service.NewExampleService(repo, logging.Nop())
handler := NewExample(svc, logging.Nop())
return handler, repo
}
func TestExample_List(t *testing.T) {
handler, repo := newTestHandler()
// Seed data
ex, _ := domain.NewExample("test-id-1", "Test Example", "Description")
_ = repo.Create(context.Background(), ex)
r := chi.NewRouter()
r.Get("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) {
if err := handler.List(w, r); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
req := httptest.NewRequest(http.MethodGet, "/api/v1/examples", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
data, ok := resp["data"]
if !ok {
t.Fatal("expected 'data' field in response")
}
items, ok := data.([]any)
if !ok {
t.Fatal("expected 'data' to be an array")
}
if len(items) != 1 {
t.Errorf("expected 1 item, got %d", len(items))
}
}
func TestExample_Get(t *testing.T) {
handler, repo := newTestHandler()
// Seed data
ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Test Example", "Description")
_ = repo.Create(context.Background(), ex)
tests := []struct {
name string
id string
wantStatus int
}{
{
name: "valid uuid - found",
id: "550e8400-e29b-41d4-a716-446655440000",
wantStatus: http.StatusOK,
},
{
name: "valid uuid - not found",
id: "550e8400-e29b-41d4-a716-446655440001",
wantStatus: http.StatusNotFound,
},
{
name: "invalid uuid",
id: "not-a-uuid",
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := chi.NewRouter()
r.Get("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
if err := handler.Get(w, r); err != nil {
// Map error to status for testing
switch tt.wantStatus {
case http.StatusNotFound:
w.WriteHeader(http.StatusNotFound)
case http.StatusBadRequest:
w.WriteHeader(http.StatusBadRequest)
default:
w.WriteHeader(http.StatusInternalServerError)
}
return
}
})
req := httptest.NewRequest(http.MethodGet, "/api/v1/examples/"+tt.id, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
}
})
}
}
func TestExample_Create(t *testing.T) {
handler, repo := newTestHandler()
// Seed existing data for duplicate test
ex, _ := domain.NewExample("existing-id", "Existing Name", "")
_ = repo.Create(context.Background(), ex)
tests := []struct {
name string
body any
wantStatus int
}{
{
name: "valid request",
body: CreateRequest{
Name: "New Example",
Description: "A test description",
},
wantStatus: http.StatusCreated,
},
{
name: "empty body",
body: nil,
wantStatus: http.StatusBadRequest,
},
{
name: "duplicate name",
body: CreateRequest{
Name: "Existing Name",
Description: "Conflict",
},
wantStatus: http.StatusConflict,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := chi.NewRouter()
r.Post("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) {
if err := handler.Create(w, r); err != nil {
switch tt.wantStatus {
case http.StatusBadRequest:
w.WriteHeader(http.StatusBadRequest)
case http.StatusConflict:
w.WriteHeader(http.StatusConflict)
default:
w.WriteHeader(http.StatusInternalServerError)
}
return
}
})
var body []byte
if tt.body != nil {
var err error
body, err = json.Marshal(tt.body)
if err != nil {
t.Fatalf("failed to marshal body: %v", err)
}
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/examples", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
}
})
}
}
func TestExample_Delete(t *testing.T) {
handler, repo := newTestHandler()
// Seed data
ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "To Delete", "")
_ = repo.Create(context.Background(), ex)
tests := []struct {
name string
id string
wantStatus int
}{
{
name: "existing example",
id: "550e8400-e29b-41d4-a716-446655440000",
wantStatus: http.StatusNoContent,
},
{
name: "non-existent example",
id: "550e8400-e29b-41d4-a716-446655440001",
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := chi.NewRouter()
r.Delete("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
if err := handler.Delete(w, r); err != nil {
if tt.wantStatus == http.StatusNotFound {
w.WriteHeader(http.StatusNotFound)
} else {
w.WriteHeader(http.StatusBadRequest)
}
return
}
})
req := httptest.NewRequest(http.MethodDelete, "/api/v1/examples/"+tt.id, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
}
})
}
}
func TestExample_Update(t *testing.T) {
handler, repo := newTestHandler()
// Seed data
ex1, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Example 1", "")
_ = repo.Create(context.Background(), ex1)
ex2, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440001", "Example 2", "")
_ = repo.Create(context.Background(), ex2)
tests := []struct {
name string
id string
body UpdateRequest
wantStatus int
}{
{
name: "valid update",
id: "550e8400-e29b-41d4-a716-446655440000",
body: UpdateRequest{
Name: "Updated Name",
Description: "Updated",
},
wantStatus: http.StatusOK,
},
{
name: "name conflict",
id: "550e8400-e29b-41d4-a716-446655440000",
body: UpdateRequest{
Name: "Example 2",
Description: "Conflict",
},
wantStatus: http.StatusConflict,
},
{
name: "not found",
id: "550e8400-e29b-41d4-a716-446655440099",
body: UpdateRequest{
Name: "Whatever",
Description: "",
},
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := chi.NewRouter()
r.Put("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
if err := handler.Update(w, r); err != nil {
switch tt.wantStatus {
case http.StatusNotFound:
w.WriteHeader(http.StatusNotFound)
case http.StatusConflict:
w.WriteHeader(http.StatusConflict)
default:
w.WriteHeader(http.StatusBadRequest)
}
return
}
})
body, _ := json.Marshal(tt.body)
req := httptest.NewRequest(http.MethodPut, "/api/v1/examples/"+tt.id, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
}
})
}
}

View File

@ -0,0 +1,91 @@
package handlers
import (
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"git.threesix.ai/jordan/slack5-1770529463/pkg/app"
"git.threesix.ai/jordan/slack5-1770529463/pkg/httperror"
"git.threesix.ai/jordan/slack5-1770529463/pkg/httpresponse"
"git.threesix.ai/jordan/slack5-1770529463/pkg/logging"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/service"
)
// Preference handles HTTP requests for user preferences.
type Preference struct {
svc *service.PreferenceService
logger *logging.Logger
}
// NewPreference creates a new Preference handler with injected dependencies.
func NewPreference(svc *service.PreferenceService, logger *logging.Logger) *Preference {
return &Preference{
svc: svc,
logger: logger.WithComponent("PreferenceHandler"),
}
}
// UpdatePreferencesRequest is the request body for updating preferences.
type UpdatePreferencesRequest struct {
Preferences map[string]any `json:"preferences"`
}
// PreferencesResponse is the response for preference endpoints.
type PreferencesResponse struct {
UserID string `json:"user_id"`
Preferences map[string]any `json:"preferences"`
}
// GetPreferences returns all preferences for a user.
func (h *Preference) GetPreferences(w http.ResponseWriter, r *http.Request) error {
userID := chi.URLParam(r, "user_id")
result, err := h.svc.GetPreferences(r.Context(), userID)
if err != nil {
return mapPreferenceError(err)
}
httpresponse.OK(w, r, PreferencesResponse{
UserID: result.UserID,
Preferences: result.Preferences,
})
return nil
}
// UpdatePreferences creates or updates preferences for a user.
func (h *Preference) UpdatePreferences(w http.ResponseWriter, r *http.Request) error {
userID := chi.URLParam(r, "user_id")
var req UpdatePreferencesRequest
if err := app.Bind(r, &req); err != nil {
return err
}
result, err := h.svc.UpdatePreferences(r.Context(), userID, req.Preferences)
if err != nil {
return mapPreferenceError(err)
}
httpresponse.OK(w, r, PreferencesResponse{
UserID: result.UserID,
Preferences: result.Preferences,
})
return nil
}
// mapPreferenceError converts domain errors to HTTP errors.
func mapPreferenceError(err error) error {
switch {
case errors.Is(err, domain.ErrInvalidUserID):
return httperror.BadRequest("invalid user_id format: must be a valid UUID")
case errors.Is(err, domain.ErrUnknownPreferenceKey):
return httperror.BadRequest(err.Error())
case errors.Is(err, domain.ErrInvalidPreferenceValue):
return httperror.BadRequest(err.Error())
default:
return err
}
}

View File

@ -0,0 +1,301 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/go-chi/chi/v5"
"git.threesix.ai/jordan/slack5-1770529463/pkg/app"
"git.threesix.ai/jordan/slack5-1770529463/pkg/logging"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/service"
)
// mockPreferenceRepository implements port.PreferenceRepository for testing.
type mockPreferenceRepository struct {
mu sync.RWMutex
prefs map[string]map[string]string
}
var _ port.PreferenceRepository = (*mockPreferenceRepository)(nil)
func newMockPreferenceRepository() *mockPreferenceRepository {
return &mockPreferenceRepository{
prefs: make(map[string]map[string]string),
}
}
func (m *mockPreferenceRepository) GetByUserID(ctx context.Context, userID string) ([]port.PreferenceRow, error) {
m.mu.RLock()
defer m.mu.RUnlock()
userPrefs, ok := m.prefs[userID]
if !ok {
return nil, nil
}
rows := make([]port.PreferenceRow, 0, len(userPrefs))
for k, v := range userPrefs {
rows = append(rows, port.PreferenceRow{
UserID: userID,
Key: k,
Value: v,
})
}
return rows, nil
}
func (m *mockPreferenceRepository) Upsert(ctx context.Context, userID string, key string, value string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.prefs[userID] == nil {
m.prefs[userID] = make(map[string]string)
}
m.prefs[userID][key] = value
return nil
}
func newTestPreferenceHandler() (*Preference, *mockPreferenceRepository) {
repo := newMockPreferenceRepository()
svc := service.NewPreferenceService(repo, logging.Nop())
handler := NewPreference(svc, logging.Nop())
return handler, repo
}
func TestPreference_GetPreferences(t *testing.T) {
handler, repo := newTestPreferenceHandler()
validUserID := "550e8400-e29b-41d4-a716-446655440000"
t.Run("returns defaults for new user", func(t *testing.T) {
r := chi.NewRouter()
r.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.GetPreferences))
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/"+validUserID, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
data, ok := resp["data"].(map[string]any)
if !ok {
t.Fatal("expected 'data' field as object in response")
}
if data["user_id"] != validUserID {
t.Errorf("expected user_id %s, got %v", validUserID, data["user_id"])
}
prefs, ok := data["preferences"].(map[string]any)
if !ok {
t.Fatal("expected 'preferences' field as object in data")
}
if prefs["theme"] != "system" {
t.Errorf("expected default theme 'system', got %v", prefs["theme"])
}
if prefs["language"] != "en" {
t.Errorf("expected default language 'en', got %v", prefs["language"])
}
if prefs["notifications_enabled"] != true {
t.Errorf("expected default notifications_enabled true, got %v", prefs["notifications_enabled"])
}
})
t.Run("returns stored preferences merged with defaults", func(t *testing.T) {
_ = repo.Upsert(context.Background(), validUserID, "theme", "dark")
r := chi.NewRouter()
r.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.GetPreferences))
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/"+validUserID, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]any
_ = json.NewDecoder(w.Body).Decode(&resp)
data := resp["data"].(map[string]any)
prefs := data["preferences"].(map[string]any)
if prefs["theme"] != "dark" {
t.Errorf("expected theme 'dark', got %v", prefs["theme"])
}
})
t.Run("returns 400 for invalid user_id", func(t *testing.T) {
r := chi.NewRouter()
r.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.GetPreferences))
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/not-a-uuid", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
})
t.Run("response has meta field", func(t *testing.T) {
r := chi.NewRouter()
r.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.GetPreferences))
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/"+validUserID, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
var resp map[string]any
_ = json.NewDecoder(w.Body).Decode(&resp)
if _, ok := resp["meta"]; !ok {
t.Error("expected 'meta' field in response")
}
})
}
func TestPreference_UpdatePreferences(t *testing.T) {
handler, _ := newTestPreferenceHandler()
validUserID := "550e8400-e29b-41d4-a716-446655440000"
t.Run("updates preferences successfully", func(t *testing.T) {
r := chi.NewRouter()
r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.UpdatePreferences))
body, _ := json.Marshal(UpdatePreferencesRequest{
Preferences: map[string]any{
"theme": "dark",
"language": "fr",
},
})
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+validUserID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d; body: %s", w.Code, w.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(w.Body).Decode(&resp)
data := resp["data"].(map[string]any)
prefs := data["preferences"].(map[string]any)
if prefs["theme"] != "dark" {
t.Errorf("expected theme 'dark', got %v", prefs["theme"])
}
if prefs["language"] != "fr" {
t.Errorf("expected language 'fr', got %v", prefs["language"])
}
// Default should still be present
if prefs["notifications_enabled"] != true {
t.Errorf("expected notifications_enabled true, got %v", prefs["notifications_enabled"])
}
})
t.Run("returns 400 for unknown key", func(t *testing.T) {
r := chi.NewRouter()
r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.UpdatePreferences))
body, _ := json.Marshal(UpdatePreferencesRequest{
Preferences: map[string]any{
"font_size": "large",
},
})
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+validUserID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
})
t.Run("returns 400 for invalid value", func(t *testing.T) {
r := chi.NewRouter()
r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.UpdatePreferences))
body, _ := json.Marshal(UpdatePreferencesRequest{
Preferences: map[string]any{
"theme": "neon",
},
})
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+validUserID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
})
t.Run("returns 400 for invalid user_id", func(t *testing.T) {
r := chi.NewRouter()
r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.UpdatePreferences))
body, _ := json.Marshal(UpdatePreferencesRequest{
Preferences: map[string]any{
"theme": "dark",
},
})
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/bad-id", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
})
t.Run("handles boolean notifications_enabled", func(t *testing.T) {
r := chi.NewRouter()
r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.UpdatePreferences))
body, _ := json.Marshal(UpdatePreferencesRequest{
Preferences: map[string]any{
"notifications_enabled": false,
},
})
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+validUserID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d; body: %s", w.Code, w.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(w.Body).Decode(&resp)
data := resp["data"].(map[string]any)
prefs := data["preferences"].(map[string]any)
if prefs["notifications_enabled"] != false {
t.Errorf("expected notifications_enabled false, got %v", prefs["notifications_enabled"])
}
})
}

View File

@ -1,4 +1,3 @@
// Package api provides HTTP routing and handlers for the preferences-api service.
package api package api
import ( import (
@ -10,32 +9,23 @@ import (
) )
// RegisterRoutes registers all HTTP routes for the service. // RegisterRoutes registers all HTTP routes for the service.
// Routes are mounted under /api/preferences-api to match the ingress path routing. func RegisterRoutes(application *app.App, preferenceService *service.PreferenceService) {
// This allows the monorepo to expose multiple services under a single domain:
// - https://domain/api/preferences-api/health
// - https://domain/api/preferences-api/examples
func RegisterRoutes(application *app.App, exampleService *service.ExampleService) {
logger := application.Logger() logger := application.Logger()
cfg := config.Load() cfg := config.Load()
// Initialize handlers with injected services // Initialize handlers with injected services
healthHandler := handlers.NewHealth(logger) healthHandler := handlers.NewHealth(logger)
exampleHandler := handlers.NewExample(exampleService, logger) prefHandler := handlers.NewPreference(preferenceService, logger)
// Build and mount OpenAPI spec // Build and mount OpenAPI spec
spec := NewServiceSpec() spec := NewServiceSpec()
application.EnableDocs(spec) application.EnableDocs(spec)
// Register API routes under /api/{service-name} to match ingress path routing. // Register API routes under /api/{service-name} to match ingress path routing.
// The ingress routes /api/preferences-api/* to this service.
application.Route("/api/preferences-api", func(r app.Router) { application.Route("/api/preferences-api", func(r app.Router) {
r.Get("/health", healthHandler.Check) r.Get("/health", healthHandler.Check)
// Public routes (no auth required) // Preferences routes (auth-protectable)
r.Get("/examples", app.Wrap(exampleHandler.List))
r.Get("/examples/{id}", app.Wrap(exampleHandler.Get))
// Protected routes (auth required when enabled)
r.Group(func(r app.Router) { r.Group(func(r app.Router) {
if cfg.AuthEnabled { if cfg.AuthEnabled {
r.Use(auth.Middleware(auth.MiddlewareConfig{ r.Use(auth.Middleware(auth.MiddlewareConfig{
@ -46,9 +36,8 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
})) }))
} }
r.Post("/examples", app.Wrap(exampleHandler.Create)) r.Get("/preferences/{user_id}", app.Wrap(prefHandler.GetPreferences))
r.Put("/examples/{id}", app.Wrap(exampleHandler.Update)) r.Put("/preferences/{user_id}", app.Wrap(prefHandler.UpdatePreferences))
r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete))
}) })
}) })
} }

View File

@ -5,29 +5,28 @@ import "git.threesix.ai/jordan/slack5-1770529463/pkg/openapi"
// NewServiceSpec builds the OpenAPI specification for the preferences-api service. // NewServiceSpec builds the OpenAPI specification for the preferences-api service.
func NewServiceSpec() *openapi.OpenAPISpec { func NewServiceSpec() *openapi.OpenAPISpec {
spec := openapi.NewOpenAPISpec("preferences-api API", "1.0.0"). spec := openapi.NewOpenAPISpec("preferences-api API", "1.0.0").
WithDescription("REST API for the preferences-api service"). WithDescription("REST API for managing user preferences").
WithBearerSecurity("bearer", "JWT authentication token"). WithBearerSecurity("bearer", "JWT authentication token").
WithTag("Health", "Service health endpoints"). WithTag("Health", "Service health endpoints").
WithTag("Examples", "Example CRUD endpoints") WithTag("Preferences", "User preference endpoints")
// Define reusable schemas // Define reusable schemas
spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{ spec.WithSchema("UserPreferences", openapi.Object(map[string]openapi.Schema{
"id": openapi.UUID().WithDescription("Unique identifier"), "user_id": openapi.UUID().WithDescription("User identifier"),
"name": openapi.String().WithDescription("Name of the example").WithExample("My Example"), "preferences": openapi.Object(map[string]openapi.Schema{
"description": openapi.String().WithDescription("Optional description").WithExample("A description"), "theme": openapi.StringEnum("light", "dark", "system").WithDescription("UI theme").WithDefault("system"),
"created_at": openapi.DateTime().WithDescription("Creation timestamp"), "language": openapi.String().WithDescription("BCP 47 language tag").WithExample("en"),
"updated_at": openapi.DateTime().WithDescription("Last update timestamp"), "notifications_enabled": openapi.Bool().WithDescription("Whether notifications are enabled").WithDefault(true),
}, "id", "name")) }),
}, "user_id", "preferences"))
spec.WithSchema("CreateExampleRequest", openapi.Object(map[string]openapi.Schema{ spec.WithSchema("UpdatePreferencesRequest", openapi.Object(map[string]openapi.Schema{
"name": openapi.StringWithMinMax(1, 100).WithDescription("Name of the example"), "preferences": openapi.Object(map[string]openapi.Schema{
"description": openapi.StringWithMinMax(0, 500).WithDescription("Optional description"), "theme": openapi.StringEnum("light", "dark", "system").WithDescription("UI theme"),
}, "name")) "language": openapi.String().WithDescription("BCP 47 language tag").WithExample("fr"),
"notifications_enabled": openapi.Bool().WithDescription("Whether notifications are enabled"),
spec.WithSchema("UpdateExampleRequest", openapi.Object(map[string]openapi.Schema{ }),
"name": openapi.StringWithMinMax(1, 100).WithDescription("Updated name"), }, "preferences"))
"description": openapi.StringWithMinMax(0, 500).WithDescription("Updated description"),
}))
// Health // Health
spec.AddPath("/api/preferences-api/health", "get", map[string]any{ spec.AddPath("/api/preferences-api/health", "get", map[string]any{
@ -41,70 +40,30 @@ func NewServiceSpec() *openapi.OpenAPISpec {
}, },
}) })
// List examples // Get preferences
spec.AddPath("/api/preferences-api/examples", "get", map[string]any{ spec.AddPath("/api/preferences-api/preferences/{user_id}", "get", map[string]any{
"summary": "List examples", "summary": "Get user preferences",
"description": "Returns a paginated list of examples.", "description": "Returns all preferences for a user, merging stored values with server-defined defaults.",
"tags": []string{"Examples"}, "tags": []string{"Preferences"},
"parameters": []any{openapi.PageParam(), openapi.PerPageParam()},
"responses": map[string]any{
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.RefArray("Example"))),
},
})
// Get example
spec.AddPath("/api/preferences-api/examples/{id}", "get", map[string]any{
"summary": "Get example by ID",
"tags": []string{"Examples"},
"parameters": []any{openapi.IDParam()},
"responses": map[string]any{
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("Example"))),
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
},
})
// Create example
spec.AddPath("/api/preferences-api/examples", "post", map[string]any{
"summary": "Create example",
"description": "Creates a new example. Requires authentication.",
"tags": []string{"Examples"},
"security": []map[string][]string{{"bearer": {}}}, "security": []map[string][]string{{"bearer": {}}},
"requestBody": openapi.RequestBody(openapi.Ref("CreateExampleRequest"), true), "parameters": []any{openapi.PathParamWithSchema("user_id", "User identifier (UUID)", openapi.UUID())},
"responses": map[string]any{ "responses": map[string]any{
"201": openapi.OpResponse("Created", openapi.ResponseSchema(openapi.Ref("Example"))), "200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("UserPreferences"))),
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()), "400": openapi.OpResponse("Invalid user_id format", openapi.ErrorResponseSchema()),
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
"422": openapi.OpResponse("Validation error", openapi.ErrorResponseSchema()),
}, },
}) })
// Update example // Update preferences
spec.AddPath("/api/preferences-api/examples/{id}", "put", map[string]any{ spec.AddPath("/api/preferences-api/preferences/{user_id}", "put", map[string]any{
"summary": "Update example", "summary": "Update user preferences",
"description": "Updates an existing example. Requires authentication.", "description": "Creates or updates preferences for the given user. Only provided keys are updated; omitted keys retain their current value or default.",
"tags": []string{"Examples"}, "tags": []string{"Preferences"},
"security": []map[string][]string{{"bearer": {}}}, "security": []map[string][]string{{"bearer": {}}},
"parameters": []any{openapi.IDParam()}, "parameters": []any{openapi.PathParamWithSchema("user_id", "User identifier (UUID)", openapi.UUID())},
"requestBody": openapi.RequestBody(openapi.Ref("UpdateExampleRequest"), true), "requestBody": openapi.RequestBody(openapi.Ref("UpdatePreferencesRequest"), true),
"responses": map[string]any{ "responses": map[string]any{
"200": openapi.OpResponse("Updated", openapi.ResponseSchema(openapi.Ref("Example"))), "200": openapi.OpResponse("Updated", openapi.ResponseSchema(openapi.Ref("UserPreferences"))),
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()), "400": openapi.OpResponse("Bad request (invalid user_id, unknown key, or invalid value)", openapi.ErrorResponseSchema()),
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
},
})
// Delete example
spec.AddPath("/api/preferences-api/examples/{id}", "delete", map[string]any{
"summary": "Delete example",
"description": "Deletes an example by ID. Requires authentication.",
"tags": []string{"Examples"},
"security": []map[string][]string{{"bearer": {}}},
"parameters": []any{openapi.IDParam()},
"responses": map[string]any{
"204": openapi.OpResponseNoContent(),
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
}, },
}) })

View File

@ -1,21 +1,15 @@
// Package domain contains pure domain models with no external dependencies.
// These types represent the core business concepts of the service.
package domain package domain
import "errors" import "errors"
// Domain errors - these are business-level errors that should be translated // Domain errors for user preferences.
// to appropriate HTTP status codes by the handler layer.
var ( var (
// ErrNotFound indicates a requested resource does not exist. // ErrUnknownPreferenceKey indicates an unknown preference key was provided.
ErrNotFound = errors.New("not found") ErrUnknownPreferenceKey = errors.New("unknown preference key")
// ErrExampleNotFound indicates the requested example does not exist. // ErrInvalidPreferenceValue indicates a preference value failed validation.
ErrExampleNotFound = errors.New("example not found") ErrInvalidPreferenceValue = errors.New("invalid preference value")
// ErrDuplicateExample indicates an example with the same name already exists. // ErrInvalidUserID indicates the user_id is not a valid UUID.
ErrDuplicateExample = errors.New("example with this name already exists") ErrInvalidUserID = errors.New("invalid user id")
// ErrInvalidExampleName indicates the example name is invalid.
ErrInvalidExampleName = errors.New("invalid example name")
) )

View File

@ -1,89 +0,0 @@
package domain
import (
"time"
"unicode/utf8"
)
// ExampleID is a strongly-typed identifier for examples.
type ExampleID string
// String returns the string representation of the ID.
func (id ExampleID) String() string {
return string(id)
}
// IsZero returns true if the ID is empty.
func (id ExampleID) IsZero() bool {
return id == ""
}
// Example name constraints.
const (
MinExampleNameLen = 1
MaxExampleNameLen = 100
MaxDescriptionLen = 500
)
// Example represents an example domain entity.
// This is a pure domain model with no external dependencies.
type Example struct {
ID ExampleID
Name string
Description string
CreatedAt time.Time
UpdatedAt time.Time
}
// NewExample creates a new Example with validation.
// Returns ErrInvalidExampleName if the name is invalid.
func NewExample(id ExampleID, name, description string) (*Example, error) {
if err := validateExampleName(name); err != nil {
return nil, err
}
if err := validateDescription(description); err != nil {
return nil, err
}
now := time.Now().UTC()
return &Example{
ID: id,
Name: name,
Description: description,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
// Update modifies the example's mutable fields with validation.
// Returns ErrInvalidExampleName if the name is invalid.
func (e *Example) Update(name, description string) error {
if err := validateExampleName(name); err != nil {
return err
}
if err := validateDescription(description); err != nil {
return err
}
e.Name = name
e.Description = description
e.UpdatedAt = time.Now().UTC()
return nil
}
// validateExampleName validates an example name.
func validateExampleName(name string) error {
length := utf8.RuneCountInString(name)
if length < MinExampleNameLen || length > MaxExampleNameLen {
return ErrInvalidExampleName
}
return nil
}
// validateDescription validates a description.
func validateDescription(desc string) error {
if utf8.RuneCountInString(desc) > MaxDescriptionLen {
return ErrInvalidExampleName
}
return nil
}

View File

@ -0,0 +1,133 @@
package domain
import (
"fmt"
"regexp"
)
// PreferenceKey is a typed key for a known preference.
type PreferenceKey string
const (
KeyTheme PreferenceKey = "theme"
KeyLanguage PreferenceKey = "language"
KeyNotificationsEnabled PreferenceKey = "notifications_enabled"
)
// PreferenceDefinition describes a known preference key with its default and validator.
type PreferenceDefinition struct {
Key PreferenceKey
DefaultValue string
Validate func(value string) error
}
// UserPreferences is the aggregate representing all preferences for a user.
type UserPreferences struct {
UserID string
Preferences map[PreferenceKey]string
}
// uuidRegex validates UUID format.
var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
// bcp47Regex validates BCP 47 language tags.
var bcp47Regex = regexp.MustCompile(`^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{1,8})*$`)
// validThemes is the set of allowed theme values.
var validThemes = map[string]bool{
"light": true,
"dark": true,
"system": true,
}
// registry holds the definitions for all known preference keys.
var registry = map[PreferenceKey]PreferenceDefinition{
KeyTheme: {
Key: KeyTheme,
DefaultValue: "system",
Validate: func(value string) error {
if !validThemes[value] {
return fmt.Errorf("%w: theme must be one of: light, dark, system", ErrInvalidPreferenceValue)
}
return nil
},
},
KeyLanguage: {
Key: KeyLanguage,
DefaultValue: "en",
Validate: func(value string) error {
if !bcp47Regex.MatchString(value) {
return fmt.Errorf("%w: language must be a valid BCP 47 tag", ErrInvalidPreferenceValue)
}
return nil
},
},
KeyNotificationsEnabled: {
Key: KeyNotificationsEnabled,
DefaultValue: "true",
Validate: func(value string) error {
if value != "true" && value != "false" {
return fmt.Errorf("%w: notifications_enabled must be true or false", ErrInvalidPreferenceValue)
}
return nil
},
},
}
// ValidateUserID checks that userID is a valid UUID.
func ValidateUserID(userID string) error {
if !uuidRegex.MatchString(userID) {
return ErrInvalidUserID
}
return nil
}
// ValidateKey checks that key is a known preference key.
func ValidateKey(key string) error {
if _, ok := registry[PreferenceKey(key)]; !ok {
return fmt.Errorf("%w: %s", ErrUnknownPreferenceKey, key)
}
return nil
}
// ValidateValue validates a value for the given preference key.
func ValidateValue(key PreferenceKey, value string) error {
def, ok := registry[key]
if !ok {
return fmt.Errorf("%w: %s", ErrUnknownPreferenceKey, key)
}
return def.Validate(value)
}
// DefaultPreferences returns all known keys with their default values.
func DefaultPreferences() map[PreferenceKey]string {
defaults := make(map[PreferenceKey]string, len(registry))
for k, def := range registry {
defaults[k] = def.DefaultValue
}
return defaults
}
// MergeWithDefaults fills in missing keys from defaults, preserving stored values.
func MergeWithDefaults(stored map[PreferenceKey]string) map[PreferenceKey]string {
merged := DefaultPreferences()
for k, v := range stored {
merged[k] = v
}
return merged
}
// SerializeForResponse converts stored string values to typed values for JSON.
// notifications_enabled "true"/"false" becomes boolean true/false.
func SerializeForResponse(prefs map[PreferenceKey]string) map[string]any {
result := make(map[string]any, len(prefs))
for k, v := range prefs {
switch k {
case KeyNotificationsEnabled:
result[string(k)] = v == "true"
default:
result[string(k)] = v
}
}
return result
}

View File

@ -0,0 +1,9 @@
package migrations
import "embed"
//go:embed sql/*.sql
var FS embed.FS
// Dir is the directory within the embedded filesystem containing migration files.
const Dir = "sql"

View File

@ -0,0 +1,10 @@
CREATE TABLE user_preferences (
user_id UUID NOT NULL,
key VARCHAR(64) NOT NULL,
value TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, key)
);
CREATE INDEX idx_user_preferences_user_id ON user_preferences (user_id);

View File

@ -1,37 +0,0 @@
// Package port defines interfaces (ports) for external dependencies.
// These interfaces define the contracts between the application core and
// infrastructure adapters, enabling testability and flexibility.
package port
import (
"context"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain"
)
// ExampleRepository defines the interface for example persistence operations.
// Implementations may use databases, in-memory storage, or external services.
type ExampleRepository interface {
// List returns all examples.
List(ctx context.Context) ([]domain.Example, error)
// Get returns an example by ID.
// Returns domain.ErrExampleNotFound if not found.
Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error)
// Create stores a new example.
// The example must have a valid ID set.
Create(ctx context.Context, example *domain.Example) error
// Update modifies an existing example.
// Returns domain.ErrExampleNotFound if not found.
Update(ctx context.Context, example *domain.Example) error
// Delete removes an example by ID.
// Returns domain.ErrExampleNotFound if not found.
Delete(ctx context.Context, id domain.ExampleID) error
// ExistsByName checks if an example with the given name exists.
// Used for duplicate detection.
ExistsByName(ctx context.Context, name string) (bool, error)
}

View File

@ -0,0 +1,25 @@
package port
import (
"context"
"time"
)
// PreferenceRow represents a single preference row from the database.
type PreferenceRow struct {
UserID string
Key string
Value string
CreatedAt time.Time
UpdatedAt time.Time
}
// PreferenceRepository defines the interface for preference persistence operations.
type PreferenceRepository interface {
// GetByUserID returns all stored preferences for a user.
// Returns an empty slice (not an error) if no preferences exist.
GetByUserID(ctx context.Context, userID string) ([]PreferenceRow, error)
// Upsert creates or updates a single preference for a user.
Upsert(ctx context.Context, userID string, key string, value string) error
}

View File

@ -1,137 +0,0 @@
// Package service provides business logic / use cases for the application.
// Services orchestrate domain operations using port interfaces.
package service
import (
"context"
"errors"
"github.com/google/uuid"
"git.threesix.ai/jordan/slack5-1770529463/pkg/logging"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port"
)
// ExampleService handles example-related business logic.
type ExampleService struct {
repo port.ExampleRepository
logger *logging.Logger
}
// NewExampleService creates a new example service.
func NewExampleService(repo port.ExampleRepository, logger *logging.Logger) *ExampleService {
return &ExampleService{
repo: repo,
logger: logger.WithService("ExampleService"),
}
}
// List returns all examples.
func (s *ExampleService) List(ctx context.Context) ([]domain.Example, error) {
return s.repo.List(ctx)
}
// Get returns an example by ID.
// Returns domain.ErrExampleNotFound if not found.
func (s *ExampleService) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
return s.repo.Get(ctx, id)
}
// CreateInput contains the data needed to create an example.
type CreateInput struct {
Name string
Description string
}
// Create creates a new example with duplicate detection.
// Returns domain.ErrDuplicateExample if name already exists.
// Returns domain.ErrInvalidExampleName if name is invalid.
func (s *ExampleService) Create(ctx context.Context, input CreateInput) (*domain.Example, error) {
// Check for duplicates
exists, err := s.repo.ExistsByName(ctx, input.Name)
if err != nil {
return nil, err
}
if exists {
return nil, domain.ErrDuplicateExample
}
// Generate new ID
id := domain.ExampleID(uuid.New().String())
// Create domain entity (validates name)
example, err := domain.NewExample(id, input.Name, input.Description)
if err != nil {
return nil, err
}
// Persist
if err := s.repo.Create(ctx, example); err != nil {
return nil, err
}
s.logger.Info("example created", "id", id, "name", input.Name)
return example, nil
}
// UpdateInput contains the data needed to update an example.
type UpdateInput struct {
Name string
Description string
}
// Update modifies an existing example.
// Returns domain.ErrExampleNotFound if not found.
// Returns domain.ErrDuplicateExample if new name conflicts with another example.
// Returns domain.ErrInvalidExampleName if name is invalid.
func (s *ExampleService) Update(ctx context.Context, id domain.ExampleID, input UpdateInput) (*domain.Example, error) {
// Fetch existing
example, err := s.repo.Get(ctx, id)
if err != nil {
return nil, err
}
// Check for name conflicts (only if name changed)
if example.Name != input.Name {
exists, err := s.repo.ExistsByName(ctx, input.Name)
if err != nil {
return nil, err
}
if exists {
return nil, domain.ErrDuplicateExample
}
}
// Update domain entity (validates name)
if err := example.Update(input.Name, input.Description); err != nil {
return nil, err
}
// Persist
if err := s.repo.Update(ctx, example); err != nil {
return nil, err
}
s.logger.Info("example updated", "id", id, "name", input.Name)
return example, nil
}
// Delete removes an example by ID.
// Returns domain.ErrExampleNotFound if not found.
func (s *ExampleService) Delete(ctx context.Context, id domain.ExampleID) error {
// Verify exists before delete
if _, err := s.repo.Get(ctx, id); err != nil {
if errors.Is(err, domain.ErrExampleNotFound) {
return domain.ErrExampleNotFound
}
return err
}
if err := s.repo.Delete(ctx, id); err != nil {
return err
}
s.logger.Info("example deleted", "id", id)
return nil
}

View File

@ -1,282 +0,0 @@
package service
import (
"context"
"sync"
"testing"
"git.threesix.ai/jordan/slack5-1770529463/pkg/logging"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port"
)
// mockExampleRepository implements port.ExampleRepository for testing.
type mockExampleRepository struct {
mu sync.RWMutex
examples map[domain.ExampleID]*domain.Example
}
var _ port.ExampleRepository = (*mockExampleRepository)(nil)
func newMockExampleRepository() *mockExampleRepository {
return &mockExampleRepository{
examples: make(map[domain.ExampleID]*domain.Example),
}
}
func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]domain.Example, 0, len(m.examples))
for _, e := range m.examples {
result = append(result, *e)
}
return result, nil
}
func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
m.mu.RLock()
defer m.mu.RUnlock()
e, ok := m.examples[id]
if !ok {
return nil, domain.ErrExampleNotFound
}
// Return a copy to avoid mutation
copy := *e
return &copy, nil
}
func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error {
m.mu.Lock()
defer m.mu.Unlock()
// Store a copy
copy := *example
m.examples[example.ID] = &copy
return nil
}
func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.examples[example.ID]; !ok {
return domain.ErrExampleNotFound
}
// Store a copy
copy := *example
m.examples[example.ID] = &copy
return nil
}
func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.examples[id]; !ok {
return domain.ErrExampleNotFound
}
delete(m.examples, id)
return nil
}
func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, e := range m.examples {
if e.Name == name {
return true, nil
}
}
return false, nil
}
func TestExampleService_Create(t *testing.T) {
repo := newMockExampleRepository()
svc := NewExampleService(repo, logging.Nop())
t.Run("creates example successfully", func(t *testing.T) {
example, err := svc.Create(context.Background(), CreateInput{
Name: "Test Example",
Description: "A test description",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if example.Name != "Test Example" {
t.Errorf("expected name 'Test Example', got '%s'", example.Name)
}
if example.ID.IsZero() {
t.Error("expected non-empty ID")
}
})
t.Run("rejects duplicate name", func(t *testing.T) {
_, err := svc.Create(context.Background(), CreateInput{
Name: "Test Example",
Description: "Another description",
})
if err != domain.ErrDuplicateExample {
t.Errorf("expected ErrDuplicateExample, got %v", err)
}
})
t.Run("rejects empty name", func(t *testing.T) {
_, err := svc.Create(context.Background(), CreateInput{
Name: "",
Description: "Description",
})
if err != domain.ErrInvalidExampleName {
t.Errorf("expected ErrInvalidExampleName, got %v", err)
}
})
}
func TestExampleService_Get(t *testing.T) {
repo := newMockExampleRepository()
svc := NewExampleService(repo, logging.Nop())
// Create an example first
created, _ := svc.Create(context.Background(), CreateInput{
Name: "Get Test",
Description: "Description",
})
t.Run("returns existing example", func(t *testing.T) {
example, err := svc.Get(context.Background(), created.ID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if example.Name != "Get Test" {
t.Errorf("expected name 'Get Test', got '%s'", example.Name)
}
})
t.Run("returns not found for missing example", func(t *testing.T) {
_, err := svc.Get(context.Background(), "nonexistent-id")
if err != domain.ErrExampleNotFound {
t.Errorf("expected ErrExampleNotFound, got %v", err)
}
})
}
func TestExampleService_Update(t *testing.T) {
repo := newMockExampleRepository()
svc := NewExampleService(repo, logging.Nop())
// Create examples
example1, _ := svc.Create(context.Background(), CreateInput{
Name: "Update Test 1",
Description: "Original",
})
_, _ = svc.Create(context.Background(), CreateInput{
Name: "Update Test 2",
Description: "Other",
})
t.Run("updates example successfully", func(t *testing.T) {
updated, err := svc.Update(context.Background(), example1.ID, UpdateInput{
Name: "Updated Name",
Description: "Updated description",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if updated.Name != "Updated Name" {
t.Errorf("expected name 'Updated Name', got '%s'", updated.Name)
}
})
t.Run("allows same name on same example", func(t *testing.T) {
_, err := svc.Update(context.Background(), example1.ID, UpdateInput{
Name: "Updated Name",
Description: "Same name",
})
if err != nil {
t.Errorf("unexpected error updating with same name: %v", err)
}
})
t.Run("rejects name conflict", func(t *testing.T) {
_, err := svc.Update(context.Background(), example1.ID, UpdateInput{
Name: "Update Test 2",
Description: "Conflict",
})
if err != domain.ErrDuplicateExample {
t.Errorf("expected ErrDuplicateExample, got %v", err)
}
})
t.Run("returns not found for missing example", func(t *testing.T) {
_, err := svc.Update(context.Background(), "nonexistent-id", UpdateInput{
Name: "Anything",
Description: "",
})
if err != domain.ErrExampleNotFound {
t.Errorf("expected ErrExampleNotFound, got %v", err)
}
})
}
func TestExampleService_Delete(t *testing.T) {
repo := newMockExampleRepository()
svc := NewExampleService(repo, logging.Nop())
// Create an example first
created, _ := svc.Create(context.Background(), CreateInput{
Name: "Delete Test",
Description: "To be deleted",
})
t.Run("deletes example successfully", func(t *testing.T) {
err := svc.Delete(context.Background(), created.ID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify deleted
_, err = svc.Get(context.Background(), created.ID)
if err != domain.ErrExampleNotFound {
t.Errorf("expected ErrExampleNotFound after delete, got %v", err)
}
})
t.Run("returns not found for missing example", func(t *testing.T) {
err := svc.Delete(context.Background(), "nonexistent-id")
if err != domain.ErrExampleNotFound {
t.Errorf("expected ErrExampleNotFound, got %v", err)
}
})
}
func TestExampleService_List(t *testing.T) {
repo := newMockExampleRepository()
svc := NewExampleService(repo, logging.Nop())
t.Run("returns empty list initially", func(t *testing.T) {
examples, err := svc.List(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(examples) != 0 {
t.Errorf("expected 0 examples, got %d", len(examples))
}
})
// Create some examples
_, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 1", Description: ""})
_, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 2", Description: ""})
t.Run("returns all examples", func(t *testing.T) {
examples, err := svc.List(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(examples) != 2 {
t.Errorf("expected 2 examples, got %d", len(examples))
}
})
}

View File

@ -0,0 +1,119 @@
package service
import (
"context"
"fmt"
"git.threesix.ai/jordan/slack5-1770529463/pkg/logging"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port"
)
// PreferencesResult is the result of a preference operation.
type PreferencesResult struct {
UserID string
Preferences map[string]any
}
// PreferenceService handles preference business logic.
type PreferenceService struct {
repo port.PreferenceRepository
logger *logging.Logger
}
// NewPreferenceService creates a new preference service.
func NewPreferenceService(repo port.PreferenceRepository, logger *logging.Logger) *PreferenceService {
return &PreferenceService{
repo: repo,
logger: logger.WithService("PreferenceService"),
}
}
// GetPreferences returns all preferences for a user, merged with defaults.
func (s *PreferenceService) GetPreferences(ctx context.Context, userID string) (*PreferencesResult, error) {
if err := domain.ValidateUserID(userID); err != nil {
return nil, err
}
rows, err := s.repo.GetByUserID(ctx, userID)
if err != nil {
return nil, err
}
stored := make(map[domain.PreferenceKey]string, len(rows))
for _, row := range rows {
stored[domain.PreferenceKey(row.Key)] = row.Value
}
merged := domain.MergeWithDefaults(stored)
serialized := domain.SerializeForResponse(merged)
return &PreferencesResult{
UserID: userID,
Preferences: serialized,
}, nil
}
// UpdatePreferences validates and upserts the provided preferences, then returns the full merged result.
func (s *PreferenceService) UpdatePreferences(ctx context.Context, userID string, input map[string]any) (*PreferencesResult, error) {
if err := domain.ValidateUserID(userID); err != nil {
return nil, err
}
// Validate and serialize each input key-value pair
toUpsert := make(map[domain.PreferenceKey]string, len(input))
for key, val := range input {
if err := domain.ValidateKey(key); err != nil {
return nil, err
}
// Serialize value to string
strVal, err := serializeValue(val)
if err != nil {
return nil, fmt.Errorf("%w: %s must have a valid value", domain.ErrInvalidPreferenceValue, key)
}
pk := domain.PreferenceKey(key)
if err := domain.ValidateValue(pk, strVal); err != nil {
return nil, err
}
toUpsert[pk] = strVal
}
// Upsert each preference
for key, value := range toUpsert {
if err := s.repo.Upsert(ctx, userID, string(key), value); err != nil {
return nil, err
}
}
s.logger.Info("preferences updated", "user_id", userID, "keys_updated", len(toUpsert))
// Fetch and return full merged preferences
return s.GetPreferences(ctx, userID)
}
// serializeValue converts an any value to a string representation.
func serializeValue(val any) (string, error) {
switch v := val.(type) {
case string:
return v, nil
case bool:
if v {
return "true", nil
}
return "false", nil
case float64:
// JSON numbers are decoded as float64
if v == 1 {
return "true", nil
}
if v == 0 {
return "false", nil
}
return "", fmt.Errorf("unsupported numeric value")
default:
return "", fmt.Errorf("unsupported value type %T", val)
}
}

View File

@ -0,0 +1,253 @@
package service
import (
"context"
"sync"
"testing"
"git.threesix.ai/jordan/slack5-1770529463/pkg/logging"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port"
)
// mockPreferenceRepository implements port.PreferenceRepository for testing.
type mockPreferenceRepository struct {
mu sync.RWMutex
prefs map[string]map[string]string // userID -> key -> value
}
var _ port.PreferenceRepository = (*mockPreferenceRepository)(nil)
func newMockPreferenceRepository() *mockPreferenceRepository {
return &mockPreferenceRepository{
prefs: make(map[string]map[string]string),
}
}
func (m *mockPreferenceRepository) GetByUserID(ctx context.Context, userID string) ([]port.PreferenceRow, error) {
m.mu.RLock()
defer m.mu.RUnlock()
userPrefs, ok := m.prefs[userID]
if !ok {
return nil, nil
}
rows := make([]port.PreferenceRow, 0, len(userPrefs))
for k, v := range userPrefs {
rows = append(rows, port.PreferenceRow{
UserID: userID,
Key: k,
Value: v,
})
}
return rows, nil
}
func (m *mockPreferenceRepository) Upsert(ctx context.Context, userID string, key string, value string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.prefs[userID] == nil {
m.prefs[userID] = make(map[string]string)
}
m.prefs[userID][key] = value
return nil
}
func TestPreferenceService_GetPreferences(t *testing.T) {
repo := newMockPreferenceRepository()
svc := NewPreferenceService(repo, logging.Nop())
ctx := context.Background()
validUserID := "550e8400-e29b-41d4-a716-446655440000"
t.Run("returns defaults when no preferences stored", func(t *testing.T) {
result, err := svc.GetPreferences(ctx, validUserID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.UserID != validUserID {
t.Errorf("expected user_id %s, got %s", validUserID, result.UserID)
}
if result.Preferences["theme"] != "system" {
t.Errorf("expected default theme 'system', got %v", result.Preferences["theme"])
}
if result.Preferences["language"] != "en" {
t.Errorf("expected default language 'en', got %v", result.Preferences["language"])
}
if result.Preferences["notifications_enabled"] != true {
t.Errorf("expected default notifications_enabled true, got %v", result.Preferences["notifications_enabled"])
}
})
t.Run("merges stored values with defaults", func(t *testing.T) {
// Store only theme
_ = repo.Upsert(ctx, validUserID, "theme", "dark")
result, err := svc.GetPreferences(ctx, validUserID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Preferences["theme"] != "dark" {
t.Errorf("expected theme 'dark', got %v", result.Preferences["theme"])
}
if result.Preferences["language"] != "en" {
t.Errorf("expected default language 'en', got %v", result.Preferences["language"])
}
if result.Preferences["notifications_enabled"] != true {
t.Errorf("expected default notifications_enabled true, got %v", result.Preferences["notifications_enabled"])
}
})
t.Run("returns all stored values when all keys present", func(t *testing.T) {
userID := "660e8400-e29b-41d4-a716-446655440000"
_ = repo.Upsert(ctx, userID, "theme", "light")
_ = repo.Upsert(ctx, userID, "language", "fr")
_ = repo.Upsert(ctx, userID, "notifications_enabled", "false")
result, err := svc.GetPreferences(ctx, userID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Preferences["theme"] != "light" {
t.Errorf("expected theme 'light', got %v", result.Preferences["theme"])
}
if result.Preferences["language"] != "fr" {
t.Errorf("expected language 'fr', got %v", result.Preferences["language"])
}
if result.Preferences["notifications_enabled"] != false {
t.Errorf("expected notifications_enabled false, got %v", result.Preferences["notifications_enabled"])
}
})
t.Run("returns error for invalid user_id", func(t *testing.T) {
_, err := svc.GetPreferences(ctx, "not-a-uuid")
if err != domain.ErrInvalidUserID {
t.Errorf("expected ErrInvalidUserID, got %v", err)
}
})
}
func TestPreferenceService_UpdatePreferences(t *testing.T) {
repo := newMockPreferenceRepository()
svc := NewPreferenceService(repo, logging.Nop())
ctx := context.Background()
validUserID := "550e8400-e29b-41d4-a716-446655440000"
t.Run("updates preferences and returns full result", func(t *testing.T) {
result, err := svc.UpdatePreferences(ctx, validUserID, map[string]any{
"theme": "dark",
"language": "fr",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Preferences["theme"] != "dark" {
t.Errorf("expected theme 'dark', got %v", result.Preferences["theme"])
}
if result.Preferences["language"] != "fr" {
t.Errorf("expected language 'fr', got %v", result.Preferences["language"])
}
// notifications_enabled should be default
if result.Preferences["notifications_enabled"] != true {
t.Errorf("expected default notifications_enabled true, got %v", result.Preferences["notifications_enabled"])
}
})
t.Run("handles boolean input for notifications_enabled", func(t *testing.T) {
result, err := svc.UpdatePreferences(ctx, validUserID, map[string]any{
"notifications_enabled": false,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Preferences["notifications_enabled"] != false {
t.Errorf("expected notifications_enabled false, got %v", result.Preferences["notifications_enabled"])
}
})
t.Run("rejects unknown key", func(t *testing.T) {
_, err := svc.UpdatePreferences(ctx, validUserID, map[string]any{
"font_size": "large",
})
if err == nil {
t.Fatal("expected error for unknown key")
}
// Should wrap ErrUnknownPreferenceKey
if !containsError(err, domain.ErrUnknownPreferenceKey) {
t.Errorf("expected ErrUnknownPreferenceKey, got %v", err)
}
})
t.Run("rejects invalid theme value", func(t *testing.T) {
_, err := svc.UpdatePreferences(ctx, validUserID, map[string]any{
"theme": "neon",
})
if err == nil {
t.Fatal("expected error for invalid theme")
}
if !containsError(err, domain.ErrInvalidPreferenceValue) {
t.Errorf("expected ErrInvalidPreferenceValue, got %v", err)
}
})
t.Run("rejects invalid language value", func(t *testing.T) {
_, err := svc.UpdatePreferences(ctx, validUserID, map[string]any{
"language": "123",
})
if err == nil {
t.Fatal("expected error for invalid language")
}
if !containsError(err, domain.ErrInvalidPreferenceValue) {
t.Errorf("expected ErrInvalidPreferenceValue, got %v", err)
}
})
t.Run("rejects invalid user_id", func(t *testing.T) {
_, err := svc.UpdatePreferences(ctx, "bad-id", map[string]any{
"theme": "dark",
})
if err != domain.ErrInvalidUserID {
t.Errorf("expected ErrInvalidUserID, got %v", err)
}
})
t.Run("is idempotent", func(t *testing.T) {
userID := "770e8400-e29b-41d4-a716-446655440000"
input := map[string]any{"theme": "dark"}
result1, err := svc.UpdatePreferences(ctx, userID, input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
result2, err := svc.UpdatePreferences(ctx, userID, input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result1.Preferences["theme"] != result2.Preferences["theme"] {
t.Errorf("expected idempotent result, got %v and %v", result1.Preferences["theme"], result2.Preferences["theme"])
}
})
}
// containsError checks if err wraps target using errors.Is-like behavior with fmt.Errorf wrapping.
func containsError(err, target error) bool {
if err == nil {
return false
}
for e := err; e != nil; {
if e == target {
return true
}
if e.Error() == target.Error() {
return true
}
u, ok := e.(interface{ Unwrap() error })
if !ok {
break
}
e = u.Unwrap()
}
return false
}

BIN
services/preferences-api/server Executable file

Binary file not shown.