build: /implement-feature user-preferences
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
rdev-worker 2026-02-08 10:47:23 +00:00
parent c812bbfd13
commit a31f57382b
24 changed files with 1121 additions and 1335 deletions

View File

@ -0,0 +1,4 @@
name: feature/user-preferences
feature: user-preferences
base_branch: main
created_at: 2026-02-08T10:07:29.732415754Z

View File

@ -1,20 +1,37 @@
slug: user-preferences
title: User Preferences API
created: 2026-02-08T09:52:56.80394451Z
phase: draft
branch: feature/user-preferences
phase: implementation
phase_history:
- phase: draft
entered: 2026-02-08T09:52:56.80394451Z
exited: 2026-02-08T10:07:22.434827988Z
- phase: specified
entered: 2026-02-08T10:07:22.434827988Z
exited: 2026-02-08T10:07:25.459169807Z
- phase: planned
entered: 2026-02-08T10:07:25.459169807Z
exited: 2026-02-08T10:07:33.549375613Z
- phase: ready
entered: 2026-02-08T10:07:33.549375613Z
exited: 2026-02-08T10:07:33.557335602Z
- phase: implementation
entered: 2026-02-08T10:07:33.557335602Z
artifacts:
audit:
status: pending
path: audit.md
design:
status: draft
status: approved
path: design.md
approved_by: user
approved_at: 2026-02-08T10:07:16.189764729Z
qa_plan:
status: draft
status: approved
path: qa-plan.md
approved_by: user
approved_at: 2026-02-08T10:07:16.221949604Z
qa_results:
status: pending
path: qa-results.md
@ -22,37 +39,60 @@ artifacts:
status: pending
path: review.md
spec:
status: draft
status: approved
path: spec.md
approved_by: user
approved_at: 2026-02-08T10:07:16.176090276Z
tasks:
status: draft
status: approved
path: tasks.md
approved_by: user
approved_at: 2026-02-08T10:07:16.214241159Z
total: 9
completed: 9
tasks:
- id: task-001
title: Domain layer - preferences entity, validation, and errors
status: pending
status: complete
started_at: 2026-02-08T10:07:48.095075743Z
done_at: 2026-02-08T10:08:20.983567952Z
- id: task-002
title: Port layer - PreferencesRepository interface
status: pending
status: complete
started_at: 2026-02-08T10:08:30.961227466Z
done_at: 2026-02-08T10:38:21.689759224Z
- id: task-003
title: Database migration and PostgreSQL adapter
status: pending
status: complete
started_at: 2026-02-08T10:38:30.30068634Z
done_at: 2026-02-08T10:38:59.755417845Z
- id: task-004
title: Service layer - PreferencesService with Get and Update
status: pending
status: complete
started_at: 2026-02-08T10:39:07.649802188Z
done_at: 2026-02-08T10:39:26.495797673Z
- id: task-005
title: Service layer unit tests
status: pending
status: complete
started_at: 2026-02-08T10:39:37.053836127Z
done_at: 2026-02-08T10:40:10.074674376Z
- id: task-006
title: HTTP handlers - Get and Update preferences
status: pending
status: complete
started_at: 2026-02-08T10:40:20.433676814Z
done_at: 2026-02-08T10:40:48.526022751Z
- id: task-007
title: Handler integration tests
status: pending
status: complete
started_at: 2026-02-08T10:40:58.512051629Z
done_at: 2026-02-08T10:41:43.792163678Z
- id: task-008
title: Routes, OpenAPI spec, and main.go wiring
status: pending
status: complete
started_at: 2026-02-08T10:41:53.229145415Z
done_at: 2026-02-08T10:45:05.707062004Z
- id: task-009
title: Remove Example scaffold code
status: pending
status: complete
started_at: 2026-02-08T10:45:16.364685242Z
done_at: 2026-02-08T10:47:08.880266947Z

View File

@ -4,10 +4,11 @@ project:
active_work:
features:
- slug: user-preferences
phase: draft
branch: feature/user-preferences
phase: implementation
blocked: []
last_updated: 2026-02-08T09:52:56.804287616Z
last_action: CREATE_FEATURE
last_updated: 2026-02-08T10:47:08.881050803Z
last_action: COMPLETE_TASK
last_actor: cli
history:
- timestamp: 2026-02-08T09:52:56.804287195Z
@ -15,3 +16,93 @@ history:
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T10:07:16.176635623Z
action: APPROVE_ARTIFACT
feature: user-preferences
actor: user
result: success
- timestamp: 2026-02-08T10:07:16.205748015Z
action: APPROVE_ARTIFACT
feature: user-preferences
actor: user
result: success
- timestamp: 2026-02-08T10:07:16.214825437Z
action: APPROVE_ARTIFACT
feature: user-preferences
actor: user
result: success
- timestamp: 2026-02-08T10:07:16.222541948Z
action: APPROVE_ARTIFACT
feature: user-preferences
actor: user
result: success
- timestamp: 2026-02-08T10:07:22.435575445Z
action: TRANSITION
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T10:07:25.459897215Z
action: TRANSITION
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T10:07:29.736934247Z
action: CREATE_BRANCH
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T10:07:33.550396925Z
action: TRANSITION
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T10:07:33.558761815Z
action: TRANSITION
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T10:08:20.985622348Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T10:38:21.691403267Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T10:38:59.756466729Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T10:39:26.496643545Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T10:40:10.075382639Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T10:40:48.526822345Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T10:41:43.792893011Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T10:45:05.707787889Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T10:47:08.881049671Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success

View File

@ -2,15 +2,19 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"git.threesix.ai/jordan/slack5-1770544098/pkg/app"
"git.threesix.ai/jordan/slack5-1770544098/pkg/database"
"git.threesix.ai/jordan/slack5-1770544098/pkg/logging"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/adapter/memory"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/adapter/postgres"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/api"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/config"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/service"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/migrations"
)
func main() {
@ -33,17 +37,37 @@ func main() {
// Create logger
logger := logging.Default()
// Load configuration
cfg := config.Load()
// Connect to PostgreSQL
ctx := context.Background()
pool := database.MustConnect(ctx, cfg.Database.URL, database.Options{
MaxOpenConns: cfg.Database.MaxOpenConns,
MaxIdleConns: cfg.Database.MaxIdleConns,
ConnMaxLifetime: cfg.Database.ConnMaxLifetime,
})
// Run database migrations
database.MustRunMigrations(ctx, pool, migrations.FS, ".")
// Create adapters (repositories)
exampleRepo := memory.NewExampleRepository()
preferencesRepo := postgres.NewPreferencesRepository(pool.DB.DB)
// Create services (business logic)
exampleService := service.NewExampleService(exampleRepo, logger)
preferencesService := service.NewPreferencesService(preferencesRepo, logger)
// Create application
application := app.New("preferences-api", app.WithDefaultPort(8001))
// Register DB pool shutdown hook
application.OnShutdown(func(ctx context.Context) error {
logger.Info("closing database connection pool")
return pool.Close()
})
// Register routes with dependency injection
api.RegisterRoutes(application, exampleService)
api.RegisterRoutes(application, preferencesService)
// Start server
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-1770544098/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770544098/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,95 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"time"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/domain"
)
// PreferencesRepository implements port.PreferencesRepository using PostgreSQL.
type PreferencesRepository struct {
db *sql.DB
}
// NewPreferencesRepository creates a new PostgreSQL-backed preferences repository.
func NewPreferencesRepository(db *sql.DB) *PreferencesRepository {
return &PreferencesRepository{db: db}
}
// Get returns preferences for a user by ID.
// Returns nil when no preferences exist for the user.
func (r *PreferencesRepository) Get(ctx context.Context, userID string) (*domain.UserPreferences, error) {
var (
prefsJSON []byte
createdAt time.Time
updatedAt time.Time
)
err := r.db.QueryRowContext(ctx,
`SELECT preferences, created_at, updated_at FROM user_preferences WHERE user_id = $1`,
userID,
).Scan(&prefsJSON, &createdAt, &updatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
var prefs map[string]any
if err := json.Unmarshal(prefsJSON, &prefs); err != nil {
return nil, err
}
return &domain.UserPreferences{
UserID: userID,
Preferences: prefs,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}, nil
}
// Upsert creates or updates preferences for a user using JSONB merge.
// Returns the full merged preferences after upsert.
func (r *PreferencesRepository) Upsert(ctx context.Context, userID string, prefs map[string]any) (*domain.UserPreferences, error) {
prefsJSON, err := json.Marshal(prefs)
if err != nil {
return nil, err
}
var (
resultJSON []byte
createdAt time.Time
updatedAt time.Time
)
err = r.db.QueryRowContext(ctx, `
INSERT INTO user_preferences (user_id, preferences, created_at, updated_at)
VALUES ($1, $2, NOW(), NOW())
ON CONFLICT (user_id) DO UPDATE
SET preferences = user_preferences.preferences || $2,
updated_at = NOW()
RETURNING preferences, created_at, updated_at`,
userID, prefsJSON,
).Scan(&resultJSON, &createdAt, &updatedAt)
if err != nil {
return nil, err
}
var merged map[string]any
if err := json.Unmarshal(resultJSON, &merged); err != nil {
return nil, err
}
return &domain.UserPreferences{
UserID: userID,
Preferences: merged,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}, nil
}

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-1770544098/pkg/app"
"git.threesix.ai/jordan/slack5-1770544098/pkg/httperror"
"git.threesix.ai/jordan/slack5-1770544098/pkg/httpresponse"
"git.threesix.ai/jordan/slack5-1770544098/pkg/logging"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770544098/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-1770544098/pkg/logging"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/port"
"git.threesix.ai/jordan/slack5-1770544098/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,125 @@
package handlers
import (
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"git.threesix.ai/jordan/slack5-1770544098/pkg/app"
"git.threesix.ai/jordan/slack5-1770544098/pkg/auth"
"git.threesix.ai/jordan/slack5-1770544098/pkg/httperror"
"git.threesix.ai/jordan/slack5-1770544098/pkg/httpresponse"
"git.threesix.ai/jordan/slack5-1770544098/pkg/logging"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/service"
)
// Preferences handles HTTP requests for user preferences.
type Preferences struct {
svc *service.PreferencesService
logger *logging.Logger
}
// NewPreferences creates a new Preferences handler with injected dependencies.
func NewPreferences(svc *service.PreferencesService, logger *logging.Logger) *Preferences {
return &Preferences{
svc: svc,
logger: logger.WithComponent("PreferencesHandler"),
}
}
// UpdatePreferencesRequest is the request body for updating preferences.
type UpdatePreferencesRequest struct {
Preferences map[string]any `json:"preferences" validate:"required"`
}
// PreferencesResponse is the response DTO for user preferences.
type PreferencesResponse struct {
UserID string `json:"user_id"`
Preferences map[string]any `json:"preferences"`
UpdatedAt *time.Time `json:"updated_at"`
}
// Get returns preferences for a user.
func (h *Preferences) Get(w http.ResponseWriter, r *http.Request) error {
userID := chi.URLParam(r, "user_id")
if _, err := uuid.Parse(userID); err != nil {
return httperror.BadRequest("invalid user ID format")
}
if err := h.checkOwnership(r, userID); err != nil {
return err
}
prefs, err := h.svc.Get(r.Context(), userID)
if err != nil {
return err
}
httpresponse.OK(w, r, toPreferencesResponse(prefs))
return nil
}
// Update creates or updates preferences for a user.
func (h *Preferences) Update(w http.ResponseWriter, r *http.Request) error {
userID := chi.URLParam(r, "user_id")
if _, err := uuid.Parse(userID); err != nil {
return httperror.BadRequest("invalid user ID format")
}
var req UpdatePreferencesRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
if err := h.checkOwnership(r, userID); err != nil {
return err
}
prefs, err := h.svc.Update(r.Context(), userID, req.Preferences)
if err != nil {
return mapPreferencesDomainError(err)
}
httpresponse.OK(w, r, toPreferencesResponse(prefs))
return nil
}
// checkOwnership verifies the authenticated user owns the requested resource.
func (h *Preferences) checkOwnership(r *http.Request, userID string) error {
user := auth.MustGetUser(r.Context())
if user.ID != userID {
return httperror.Forbidden("access denied")
}
return nil
}
// toPreferencesResponse converts a domain UserPreferences to an API response.
func toPreferencesResponse(p *domain.UserPreferences) PreferencesResponse {
resp := PreferencesResponse{
UserID: p.UserID,
Preferences: p.Preferences,
}
if !p.UpdatedAt.IsZero() {
t := p.UpdatedAt
resp.UpdatedAt = &t
}
return resp
}
// mapPreferencesDomainError converts domain errors to HTTP errors.
func mapPreferencesDomainError(err error) error {
switch {
case errors.Is(err, domain.ErrInvalidPreferenceKey):
return httperror.BadRequest(err.Error())
case errors.Is(err, domain.ErrInvalidPreferenceValue):
return httperror.BadRequest(err.Error())
default:
return err
}
}

View File

@ -0,0 +1,285 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"git.threesix.ai/jordan/slack5-1770544098/pkg/app"
"git.threesix.ai/jordan/slack5-1770544098/pkg/auth"
"git.threesix.ai/jordan/slack5-1770544098/pkg/logging"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/port"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/service"
)
const testUserID = "550e8400-e29b-41d4-a716-446655440000"
const otherUserID = "550e8400-e29b-41d4-a716-446655440001"
// mockPrefsRepository implements port.PreferencesRepository for handler testing.
type mockPrefsRepository struct {
prefs map[string]*domain.UserPreferences
}
var _ port.PreferencesRepository = (*mockPrefsRepository)(nil)
func newMockPrefsRepository() *mockPrefsRepository {
return &mockPrefsRepository{
prefs: make(map[string]*domain.UserPreferences),
}
}
func (m *mockPrefsRepository) Get(ctx context.Context, userID string) (*domain.UserPreferences, error) {
p, ok := m.prefs[userID]
if !ok {
return nil, nil
}
cp := *p
return &cp, nil
}
func (m *mockPrefsRepository) Upsert(ctx context.Context, userID string, prefs map[string]any) (*domain.UserPreferences, error) {
existing, ok := m.prefs[userID]
if !ok {
existing = &domain.UserPreferences{
UserID: userID,
Preferences: map[string]any{},
}
}
merged := make(map[string]any)
for k, v := range existing.Preferences {
merged[k] = v
}
for k, v := range prefs {
merged[k] = v
}
result := &domain.UserPreferences{
UserID: userID,
Preferences: merged,
}
m.prefs[userID] = result
return result, nil
}
func newPrefsTestHandler() (*Preferences, *mockPrefsRepository) {
repo := newMockPrefsRepository()
svc := service.NewPreferencesService(repo, logging.Nop())
handler := NewPreferences(svc, logging.Nop())
return handler, repo
}
// withAuthUser adds an authenticated user to the request context.
func withAuthUser(r *http.Request, userID string) *http.Request {
ctx := auth.SetUser(r.Context(), &auth.User{ID: userID})
return r.WithContext(ctx)
}
func TestPreferences_Get(t *testing.T) {
tests := []struct {
name string
userID string
authUserID string
seedPrefs map[string]any
wantStatus int
wantData bool
}{
{
name: "returns 200 with preferences for existing user",
userID: testUserID,
authUserID: testUserID,
seedPrefs: map[string]any{"theme": "dark", "language": "en"},
wantStatus: http.StatusOK,
wantData: true,
},
{
name: "returns 200 with empty preferences for new user",
userID: testUserID,
authUserID: testUserID,
seedPrefs: nil,
wantStatus: http.StatusOK,
wantData: true,
},
{
name: "returns 400 for invalid UUID",
userID: "not-a-uuid",
authUserID: testUserID,
wantStatus: http.StatusBadRequest,
},
{
name: "returns 403 for ownership mismatch",
userID: testUserID,
authUserID: otherUserID,
wantStatus: http.StatusForbidden,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler, repo := newPrefsTestHandler()
if tt.seedPrefs != nil {
repo.prefs[tt.userID] = &domain.UserPreferences{
UserID: tt.userID,
Preferences: tt.seedPrefs,
}
}
r := chi.NewRouter()
r.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Get))
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/"+tt.userID, nil)
req = withAuthUser(req, tt.authUserID)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d; body: %s", tt.wantStatus, w.Code, w.Body.String())
}
if tt.wantData {
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' object in response")
}
if _, ok := resp["meta"]; !ok {
t.Fatal("expected 'meta' field in response")
}
if data["user_id"] != tt.userID {
t.Errorf("expected user_id %s, got %v", tt.userID, data["user_id"])
}
prefs, ok := data["preferences"].(map[string]any)
if !ok {
t.Fatal("expected 'preferences' map in data")
}
if tt.seedPrefs == nil && len(prefs) != 0 {
t.Errorf("expected empty preferences, got %v", prefs)
}
if tt.seedPrefs != nil {
for k, v := range tt.seedPrefs {
if prefs[k] != v {
t.Errorf("expected preferences[%s] = %v, got %v", k, v, prefs[k])
}
}
}
}
})
}
}
func TestPreferences_Update(t *testing.T) {
tests := []struct {
name string
userID string
authUserID string
body any
wantStatus int
wantData bool
}{
{
name: "returns 200 with merged preferences on success",
userID: testUserID,
authUserID: testUserID,
body: map[string]any{"preferences": map[string]any{"theme": "dark"}},
wantStatus: http.StatusOK,
wantData: true,
},
{
name: "returns 400 for unknown preference keys",
userID: testUserID,
authUserID: testUserID,
body: map[string]any{"preferences": map[string]any{"unknown": "value"}},
wantStatus: http.StatusBadRequest,
},
{
name: "returns 400 for invalid preference values",
userID: testUserID,
authUserID: testUserID,
body: map[string]any{"preferences": map[string]any{"theme": "blue"}},
wantStatus: http.StatusBadRequest,
},
{
name: "returns 400 for missing preferences field",
userID: testUserID,
authUserID: testUserID,
body: map[string]any{},
wantStatus: http.StatusBadRequest,
},
{
name: "returns 400 for invalid UUID",
userID: "not-a-uuid",
authUserID: testUserID,
body: map[string]any{"preferences": map[string]any{"theme": "dark"}},
wantStatus: http.StatusBadRequest,
},
{
name: "returns 403 for ownership mismatch",
userID: testUserID,
authUserID: otherUserID,
body: map[string]any{"preferences": map[string]any{"theme": "dark"}},
wantStatus: http.StatusForbidden,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler, _ := newPrefsTestHandler()
r := chi.NewRouter()
r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Update))
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.MethodPut, "/api/preferences-api/preferences/"+tt.userID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = withAuthUser(req, tt.authUserID)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d; body: %s", tt.wantStatus, w.Code, w.Body.String())
}
if tt.wantData {
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' object in response")
}
if _, ok := resp["meta"]; !ok {
t.Fatal("expected 'meta' field in response")
}
if data["user_id"] != tt.userID {
t.Errorf("expected user_id %s, got %v", tt.userID, data["user_id"])
}
prefs, ok := data["preferences"].(map[string]any)
if !ok {
t.Fatal("expected 'preferences' map in data")
}
if prefs["theme"] != "dark" {
t.Errorf("expected theme 'dark', got %v", prefs["theme"])
}
}
})
}
}

View File

@ -11,30 +11,22 @@ import (
// RegisterRoutes registers all HTTP routes for the service.
// Routes are mounted under /api/preferences-api to match the ingress path routing.
// 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) {
func RegisterRoutes(application *app.App, preferencesService *service.PreferencesService) {
logger := application.Logger()
cfg := config.Load()
// Initialize handlers with injected services
healthHandler := handlers.NewHealth(logger)
exampleHandler := handlers.NewExample(exampleService, logger)
prefHandler := handlers.NewPreferences(preferencesService, logger)
// Build and mount OpenAPI spec
spec := NewServiceSpec()
application.EnableDocs(spec)
// 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) {
r.Get("/health", healthHandler.Check)
// Public routes (no auth required)
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) {
if cfg.AuthEnabled {
@ -46,9 +38,9 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
}))
}
r.Post("/examples", app.Wrap(exampleHandler.Create))
r.Put("/examples/{id}", app.Wrap(exampleHandler.Update))
r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete))
// Preferences endpoints
r.Get("/preferences/{user_id}", app.Wrap(prefHandler.Get))
r.Put("/preferences/{user_id}", app.Wrap(prefHandler.Update))
})
})
}

View File

@ -8,26 +8,26 @@ func NewServiceSpec() *openapi.OpenAPISpec {
WithDescription("REST API for the preferences-api service").
WithBearerSecurity("bearer", "JWT authentication token").
WithTag("Health", "Service health endpoints").
WithTag("Examples", "Example CRUD endpoints")
WithTag("Preferences", "User preferences endpoints")
// Define reusable schemas
spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{
"id": openapi.UUID().WithDescription("Unique identifier"),
"name": openapi.String().WithDescription("Name of the example").WithExample("My Example"),
"description": openapi.String().WithDescription("Optional description").WithExample("A description"),
"created_at": openapi.DateTime().WithDescription("Creation timestamp"),
"updated_at": openapi.DateTime().WithDescription("Last update timestamp"),
}, "id", "name"))
spec.WithSchema("PreferencesResponse", openapi.Object(map[string]openapi.Schema{
"user_id": openapi.UUID().WithDescription("User identifier"),
"preferences": openapi.Object(map[string]openapi.Schema{
"theme": openapi.StringEnum("light", "dark").WithDescription("UI theme"),
"language": openapi.String().WithDescription("ISO 639-1 language code").WithPattern("^[a-z]{2}$").WithExample("en"),
"notifications_enabled": openapi.Bool().WithDescription("Whether notifications are enabled"),
}),
"updated_at": openapi.Nullable(openapi.DateTime()).WithDescription("Last update timestamp"),
}, "user_id", "preferences"))
spec.WithSchema("CreateExampleRequest", openapi.Object(map[string]openapi.Schema{
"name": openapi.StringWithMinMax(1, 100).WithDescription("Name of the example"),
"description": openapi.StringWithMinMax(0, 500).WithDescription("Optional description"),
}, "name"))
spec.WithSchema("UpdateExampleRequest", openapi.Object(map[string]openapi.Schema{
"name": openapi.StringWithMinMax(1, 100).WithDescription("Updated name"),
"description": openapi.StringWithMinMax(0, 500).WithDescription("Updated description"),
}))
spec.WithSchema("UpdatePreferencesRequest", openapi.Object(map[string]openapi.Schema{
"preferences": openapi.Object(map[string]openapi.Schema{
"theme": openapi.StringEnum("light", "dark").WithDescription("UI theme"),
"language": openapi.String().WithDescription("ISO 639-1 language code").WithPattern("^[a-z]{2}$"),
"notifications_enabled": openapi.Bool().WithDescription("Whether notifications are enabled"),
}),
}, "preferences"))
// Health
spec.AddPath("/api/preferences-api/health", "get", map[string]any{
@ -41,70 +41,38 @@ func NewServiceSpec() *openapi.OpenAPISpec {
},
})
// List examples
spec.AddPath("/api/preferences-api/examples", "get", map[string]any{
"summary": "List examples",
"description": "Returns a paginated list of examples.",
"tags": []string{"Examples"},
"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"},
// Get user preferences
spec.AddPath("/api/preferences-api/preferences/{user_id}", "get", map[string]any{
"summary": "Get user preferences",
"description": "Returns all preferences for the authenticated user. Returns empty preferences for users with no saved preferences.",
"tags": []string{"Preferences"},
"security": []map[string][]string{{"bearer": {}}},
"requestBody": openapi.RequestBody(openapi.Ref("CreateExampleRequest"), true),
"parameters": []any{
openapi.PathParamWithSchema("user_id", "User ID (UUID)", openapi.UUID()),
},
"responses": map[string]any{
"201": openapi.OpResponse("Created", openapi.ResponseSchema(openapi.Ref("Example"))),
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("PreferencesResponse"))),
"400": openapi.OpResponse("Invalid user ID format", openapi.ErrorResponseSchema()),
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
"422": openapi.OpResponse("Validation error", openapi.ErrorResponseSchema()),
"403": openapi.OpResponse("Forbidden - user ID mismatch", openapi.ErrorResponseSchema()),
},
})
// Update example
spec.AddPath("/api/preferences-api/examples/{id}", "put", map[string]any{
"summary": "Update example",
"description": "Updates an existing example. Requires authentication.",
"tags": []string{"Examples"},
// Update user preferences
spec.AddPath("/api/preferences-api/preferences/{user_id}", "put", map[string]any{
"summary": "Update user preferences",
"description": "Creates or updates preferences with upsert semantics. Only provided keys are changed; omitted keys are preserved.",
"tags": []string{"Preferences"},
"security": []map[string][]string{{"bearer": {}}},
"parameters": []any{openapi.IDParam()},
"requestBody": openapi.RequestBody(openapi.Ref("UpdateExampleRequest"), true),
"responses": map[string]any{
"200": openapi.OpResponse("Updated", openapi.ResponseSchema(openapi.Ref("Example"))),
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
"parameters": []any{
openapi.PathParamWithSchema("user_id", "User ID (UUID)", openapi.UUID()),
},
})
// 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()},
"requestBody": openapi.RequestBody(openapi.Ref("UpdatePreferencesRequest"), true),
"responses": map[string]any{
"204": openapi.OpResponseNoContent(),
"200": openapi.OpResponse("Updated", openapi.ResponseSchema(openapi.Ref("PreferencesResponse"))),
"400": openapi.OpResponse("Bad request - invalid key, value, or UUID", openapi.ErrorResponseSchema()),
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
"403": openapi.OpResponse("Forbidden - user ID mismatch", openapi.ErrorResponseSchema()),
},
})

View File

@ -10,12 +10,9 @@ var (
// ErrNotFound indicates a requested resource does not exist.
ErrNotFound = errors.New("not found")
// ErrExampleNotFound indicates the requested example does not exist.
ErrExampleNotFound = errors.New("example not found")
// ErrInvalidPreferenceKey indicates an unknown preference key was provided.
ErrInvalidPreferenceKey = errors.New("invalid preference key")
// ErrDuplicateExample indicates an example with the same name already exists.
ErrDuplicateExample = errors.New("example with this name already exists")
// ErrInvalidExampleName indicates the example name is invalid.
ErrInvalidExampleName = errors.New("invalid example name")
// ErrInvalidPreferenceValue indicates a preference value failed validation.
ErrInvalidPreferenceValue = errors.New("invalid preference value")
)

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,79 @@
package domain
import (
"fmt"
"regexp"
"time"
)
// Allowed preference keys.
var allowedKeys = map[string]bool{
"theme": true,
"language": true,
"notifications_enabled": true,
}
// Valid theme values.
var validThemes = map[string]bool{
"light": true,
"dark": true,
}
// languagePattern matches ISO 639-1 codes (two lowercase letters).
var languagePattern = regexp.MustCompile(`^[a-z]{2}$`)
// UserPreferences represents a user's stored preferences.
type UserPreferences struct {
UserID string
Preferences map[string]any
CreatedAt time.Time
UpdatedAt time.Time
}
// ValidatePreferences validates all keys and values in a preferences map.
func ValidatePreferences(prefs map[string]any) error {
for key, value := range prefs {
if err := ValidatePreferenceKey(key); err != nil {
return err
}
if err := ValidatePreferenceValue(key, value); err != nil {
return err
}
}
return nil
}
// ValidatePreferenceKey checks that the key is in the allowed set.
func ValidatePreferenceKey(key string) error {
if !allowedKeys[key] {
return fmt.Errorf("%w: %s", ErrInvalidPreferenceKey, key)
}
return nil
}
// ValidatePreferenceValue checks that the value is valid for the given key.
func ValidatePreferenceValue(key string, value any) error {
switch key {
case "theme":
s, ok := value.(string)
if !ok {
return fmt.Errorf("%w: theme must be a string", ErrInvalidPreferenceValue)
}
if !validThemes[s] {
return fmt.Errorf("%w: theme must be \"light\" or \"dark\", got %q", ErrInvalidPreferenceValue, s)
}
case "language":
s, ok := value.(string)
if !ok {
return fmt.Errorf("%w: language must be a string", ErrInvalidPreferenceValue)
}
if !languagePattern.MatchString(s) {
return fmt.Errorf("%w: language must be a valid ISO 639-1 code (e.g. \"en\"), got %q", ErrInvalidPreferenceValue, s)
}
case "notifications_enabled":
if _, ok := value.(bool); !ok {
return fmt.Errorf("%w: notifications_enabled must be a boolean", ErrInvalidPreferenceValue)
}
}
return nil
}

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-1770544098/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,19 @@
package port
import (
"context"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/domain"
)
// PreferencesRepository defines the interface for user preferences persistence.
type PreferencesRepository interface {
// Get returns preferences for a user by ID.
// Returns nil (not error) when no preferences exist for the user.
Get(ctx context.Context, userID string) (*domain.UserPreferences, error)
// Upsert creates or updates preferences for a user.
// Only provided keys are changed; omitted keys are preserved (merge behavior).
// Returns the full merged preferences after upsert.
Upsert(ctx context.Context, userID string, prefs map[string]any) (*domain.UserPreferences, 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-1770544098/pkg/logging"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770544098/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-1770544098/pkg/logging"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770544098/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,57 @@
package service
import (
"context"
"git.threesix.ai/jordan/slack5-1770544098/pkg/logging"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/port"
)
// PreferencesService handles user preferences business logic.
type PreferencesService struct {
repo port.PreferencesRepository
logger *logging.Logger
}
// NewPreferencesService creates a new preferences service.
func NewPreferencesService(repo port.PreferencesRepository, logger *logging.Logger) *PreferencesService {
return &PreferencesService{
repo: repo,
logger: logger.WithService("PreferencesService"),
}
}
// Get returns preferences for a user.
// Returns an empty preferences struct (not nil) for users with no saved preferences.
func (s *PreferencesService) Get(ctx context.Context, userID string) (*domain.UserPreferences, error) {
prefs, err := s.repo.Get(ctx, userID)
if err != nil {
return nil, err
}
if prefs == nil {
return &domain.UserPreferences{
UserID: userID,
Preferences: map[string]any{},
}, nil
}
return prefs, nil
}
// Update validates and persists preferences for a user.
// Returns the full merged preferences after upsert.
func (s *PreferencesService) Update(ctx context.Context, userID string, prefs map[string]any) (*domain.UserPreferences, error) {
if err := domain.ValidatePreferences(prefs); err != nil {
return nil, err
}
result, err := s.repo.Upsert(ctx, userID, prefs)
if err != nil {
return nil, err
}
s.logger.Info("preferences updated", "user_id", userID)
return result, nil
}

View File

@ -0,0 +1,219 @@
package service
import (
"context"
"errors"
"testing"
"git.threesix.ai/jordan/slack5-1770544098/pkg/logging"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/port"
)
// mockPreferencesRepository implements port.PreferencesRepository for testing.
type mockPreferencesRepository struct {
prefs map[string]*domain.UserPreferences
err error // inject error for testing error paths
}
var _ port.PreferencesRepository = (*mockPreferencesRepository)(nil)
func newMockPreferencesRepository() *mockPreferencesRepository {
return &mockPreferencesRepository{
prefs: make(map[string]*domain.UserPreferences),
}
}
func (m *mockPreferencesRepository) Get(ctx context.Context, userID string) (*domain.UserPreferences, error) {
if m.err != nil {
return nil, m.err
}
p, ok := m.prefs[userID]
if !ok {
return nil, nil
}
cp := *p
return &cp, nil
}
func (m *mockPreferencesRepository) Upsert(ctx context.Context, userID string, prefs map[string]any) (*domain.UserPreferences, error) {
if m.err != nil {
return nil, m.err
}
existing, ok := m.prefs[userID]
if !ok {
existing = &domain.UserPreferences{
UserID: userID,
Preferences: map[string]any{},
}
}
// Merge preferences
merged := make(map[string]any)
for k, v := range existing.Preferences {
merged[k] = v
}
for k, v := range prefs {
merged[k] = v
}
result := &domain.UserPreferences{
UserID: userID,
Preferences: merged,
}
m.prefs[userID] = result
return result, nil
}
func TestPreferencesService_Get(t *testing.T) {
t.Run("returns empty preferences for new user", func(t *testing.T) {
repo := newMockPreferencesRepository()
svc := NewPreferencesService(repo, logging.Nop())
prefs, err := svc.Get(context.Background(), "user-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if prefs.UserID != "user-1" {
t.Errorf("expected user_id 'user-1', got '%s'", prefs.UserID)
}
if len(prefs.Preferences) != 0 {
t.Errorf("expected empty preferences, got %v", prefs.Preferences)
}
})
t.Run("returns existing preferences", func(t *testing.T) {
repo := newMockPreferencesRepository()
repo.prefs["user-1"] = &domain.UserPreferences{
UserID: "user-1",
Preferences: map[string]any{"theme": "dark"},
}
svc := NewPreferencesService(repo, logging.Nop())
prefs, err := svc.Get(context.Background(), "user-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if prefs.Preferences["theme"] != "dark" {
t.Errorf("expected theme 'dark', got '%v'", prefs.Preferences["theme"])
}
})
t.Run("returns error on repository failure", func(t *testing.T) {
repo := newMockPreferencesRepository()
repo.err = errors.New("db connection failed")
svc := NewPreferencesService(repo, logging.Nop())
_, err := svc.Get(context.Background(), "user-1")
if err == nil {
t.Fatal("expected error, got nil")
}
})
}
func TestPreferencesService_Update(t *testing.T) {
t.Run("updates with valid preferences", func(t *testing.T) {
repo := newMockPreferencesRepository()
svc := NewPreferencesService(repo, logging.Nop())
result, err := svc.Update(context.Background(), "user-1", map[string]any{
"theme": "dark",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Preferences["theme"] != "dark" {
t.Errorf("expected theme 'dark', got '%v'", result.Preferences["theme"])
}
})
t.Run("rejects unknown preference key", func(t *testing.T) {
repo := newMockPreferencesRepository()
svc := NewPreferencesService(repo, logging.Nop())
_, err := svc.Update(context.Background(), "user-1", map[string]any{
"unknown_key": "value",
})
if !errors.Is(err, domain.ErrInvalidPreferenceKey) {
t.Errorf("expected ErrInvalidPreferenceKey, got %v", err)
}
})
t.Run("rejects invalid theme value", func(t *testing.T) {
repo := newMockPreferencesRepository()
svc := NewPreferencesService(repo, logging.Nop())
_, err := svc.Update(context.Background(), "user-1", map[string]any{
"theme": "blue",
})
if !errors.Is(err, domain.ErrInvalidPreferenceValue) {
t.Errorf("expected ErrInvalidPreferenceValue, got %v", err)
}
})
t.Run("rejects invalid language format", func(t *testing.T) {
repo := newMockPreferencesRepository()
svc := NewPreferencesService(repo, logging.Nop())
_, err := svc.Update(context.Background(), "user-1", map[string]any{
"language": "english",
})
if !errors.Is(err, domain.ErrInvalidPreferenceValue) {
t.Errorf("expected ErrInvalidPreferenceValue, got %v", err)
}
})
t.Run("rejects non-boolean notifications_enabled", func(t *testing.T) {
repo := newMockPreferencesRepository()
svc := NewPreferencesService(repo, logging.Nop())
_, err := svc.Update(context.Background(), "user-1", map[string]any{
"notifications_enabled": "yes",
})
if !errors.Is(err, domain.ErrInvalidPreferenceValue) {
t.Errorf("expected ErrInvalidPreferenceValue, got %v", err)
}
})
t.Run("returns error on repository failure", func(t *testing.T) {
repo := newMockPreferencesRepository()
repo.err = errors.New("db write failed")
svc := NewPreferencesService(repo, logging.Nop())
_, err := svc.Update(context.Background(), "user-1", map[string]any{
"theme": "dark",
})
if err == nil {
t.Fatal("expected error, got nil")
}
})
t.Run("merges with existing preferences", func(t *testing.T) {
repo := newMockPreferencesRepository()
svc := NewPreferencesService(repo, logging.Nop())
// Set initial preference
_, err := svc.Update(context.Background(), "user-1", map[string]any{
"theme": "dark",
})
if err != nil {
t.Fatalf("unexpected error on first update: %v", err)
}
// Update with different key
result, err := svc.Update(context.Background(), "user-1", map[string]any{
"language": "en",
})
if err != nil {
t.Fatalf("unexpected error on second update: %v", err)
}
if result.Preferences["theme"] != "dark" {
t.Errorf("expected theme 'dark' to be preserved, got '%v'", result.Preferences["theme"])
}
if result.Preferences["language"] != "en" {
t.Errorf("expected language 'en', got '%v'", result.Preferences["language"])
}
})
}

View File

@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS user_preferences (
user_id UUID PRIMARY KEY,
preferences JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@ -0,0 +1,8 @@
package migrations
import "embed"
// FS contains the embedded SQL migration files.
//
//go:embed *.sql
var FS embed.FS

BIN
services/preferences-api/server Executable file

Binary file not shown.