From 1afe983cd69afd7447ab51145d1e9b011f91d980 Mon Sep 17 00:00:00 2001 From: rdev-worker Date: Sun, 8 Feb 2026 02:02:18 +0000 Subject: [PATCH] build: /implement-feature user-preferences --- .sdlc/features/user-preferences/manifest.yaml | 63 ++- .sdlc/state.yaml | 86 +++- services/preferences-api/cmd/server/main.go | 17 +- .../internal/adapter/memory/example.go | 106 ----- .../internal/adapter/memory/preferences.go | 50 +++ .../adapter/memory/preferences_test.go | 73 ++++ .../internal/api/handlers/example.go | 170 -------- .../internal/api/handlers/example_test.go | 402 ------------------ .../internal/api/handlers/preferences.go | 210 +++++++++ .../internal/api/handlers/preferences_test.go | 303 +++++++++++++ .../preferences-api/internal/api/routes.go | 20 +- services/preferences-api/internal/api/spec.go | 121 +++--- .../preferences-api/internal/domain/errors.go | 20 +- .../internal/domain/example.go | 89 ---- .../internal/domain/preferences.go | 112 +++++ .../internal/domain/preferences_test.go | 211 +++++++++ .../preferences-api/internal/port/example.go | 37 -- .../internal/port/preferences.go | 17 + .../internal/service/example.go | 137 ------ .../internal/service/example_test.go | 282 ------------ .../internal/service/preferences.go | 59 +++ .../internal/service/preferences_test.go | 153 +++++++ 22 files changed, 1382 insertions(+), 1356 deletions(-) delete mode 100644 services/preferences-api/internal/adapter/memory/example.go create mode 100644 services/preferences-api/internal/adapter/memory/preferences.go create mode 100644 services/preferences-api/internal/adapter/memory/preferences_test.go delete mode 100644 services/preferences-api/internal/api/handlers/example.go delete mode 100644 services/preferences-api/internal/api/handlers/example_test.go create mode 100644 services/preferences-api/internal/api/handlers/preferences.go create mode 100644 services/preferences-api/internal/api/handlers/preferences_test.go delete mode 100644 services/preferences-api/internal/domain/example.go create mode 100644 services/preferences-api/internal/domain/preferences.go create mode 100644 services/preferences-api/internal/domain/preferences_test.go delete mode 100644 services/preferences-api/internal/port/example.go create mode 100644 services/preferences-api/internal/port/preferences.go delete mode 100644 services/preferences-api/internal/service/example.go delete mode 100644 services/preferences-api/internal/service/example_test.go create mode 100644 services/preferences-api/internal/service/preferences.go create mode 100644 services/preferences-api/internal/service/preferences_test.go diff --git a/.sdlc/features/user-preferences/manifest.yaml b/.sdlc/features/user-preferences/manifest.yaml index 923348d..b8e7e73 100644 --- a/.sdlc/features/user-preferences/manifest.yaml +++ b/.sdlc/features/user-preferences/manifest.yaml @@ -1,20 +1,36 @@ slug: user-preferences title: User Preferences API created: 2026-02-08T01:41:06.381540844Z -phase: draft +phase: implementation phase_history: - phase: draft entered: 2026-02-08T01:41:06.381540844Z + exited: 2026-02-08T01:55:28.531349406Z + - phase: specified + entered: 2026-02-08T01:55:28.531349406Z + exited: 2026-02-08T01:55:40.135247101Z + - phase: planned + entered: 2026-02-08T01:55:40.135247101Z + exited: 2026-02-08T01:55:43.596783684Z + - phase: ready + entered: 2026-02-08T01:55:43.596783684Z + exited: 2026-02-08T01:55:43.600701486Z + - phase: implementation + entered: 2026-02-08T01:55:43.600701486Z artifacts: audit: status: pending path: audit.md design: - status: draft + status: approved path: design.md + approved_by: user + approved_at: 2026-02-08T01:54:48.460583158Z qa_plan: - status: draft + status: approved path: qa-plan.md + approved_by: user + approved_at: 2026-02-08T01:55:40.125951198Z qa_results: status: pending path: qa-results.md @@ -22,34 +38,55 @@ artifacts: status: pending path: review.md spec: - status: draft + status: approved path: spec.md + approved_by: user + approved_at: 2026-02-08T01:54:48.456496388Z tasks: - status: draft + status: approved path: tasks.md + approved_by: user + approved_at: 2026-02-08T01:54:48.465193053Z total: 8 + completed: 8 tasks: - id: task-001 title: Remove example scaffold - delete all example entity files - status: pending + status: complete + started_at: 2026-02-08T01:55:53.824913396Z + done_at: 2026-02-08T01:56:04.69416161Z - id: task-002 title: Domain layer - preferences entity, validation, merge logic - status: pending + status: complete + started_at: 2026-02-08T01:56:04.699266877Z + done_at: 2026-02-08T01:56:55.635118124Z - id: task-003 title: Port interface - PreferencesRepository with Get and Upsert - status: pending + status: complete + started_at: 2026-02-08T01:56:55.64353869Z + done_at: 2026-02-08T01:57:11.987282578Z - id: task-004 title: In-memory adapter - thread-safe map implementation - status: pending + status: complete + started_at: 2026-02-08T01:57:11.992734588Z + done_at: 2026-02-08T01:57:36.505934659Z - id: task-005 title: Service layer - PreferencesService with Get and Upsert merge logic - status: pending + status: complete + started_at: 2026-02-08T01:57:36.512188517Z + done_at: 2026-02-08T01:58:17.276360723Z - id: task-006 title: HTTP handlers - GET and PUT with auth, validation, error mapping - status: pending + status: complete + started_at: 2026-02-08T01:58:17.283902455Z + done_at: 2026-02-08T02:00:03.080855521Z - id: task-007 title: Routes and OpenAPI spec - wire endpoints and document API - status: pending + status: complete + started_at: 2026-02-08T02:00:03.087855474Z + done_at: 2026-02-08T02:00:55.561269209Z - id: task-008 title: Wire main.go and integration - connect all layers - status: pending + status: complete + started_at: 2026-02-08T02:00:55.567098899Z + done_at: 2026-02-08T02:01:30.333129918Z diff --git a/.sdlc/state.yaml b/.sdlc/state.yaml index 2eae7f6..0e76a80 100644 --- a/.sdlc/state.yaml +++ b/.sdlc/state.yaml @@ -4,10 +4,10 @@ project: active_work: features: - slug: user-preferences - phase: draft + phase: implementation blocked: [] -last_updated: 2026-02-08T01:41:06.381901082Z -last_action: CREATE_FEATURE +last_updated: 2026-02-08T02:01:30.334181257Z +last_action: COMPLETE_TASK last_actor: cli history: - timestamp: 2026-02-08T01:41:06.381900491Z @@ -15,3 +15,83 @@ history: feature: user-preferences actor: cli result: success + - timestamp: 2026-02-08T01:54:48.457017007Z + action: APPROVE_ARTIFACT + feature: user-preferences + actor: user + result: success + - timestamp: 2026-02-08T01:54:48.461068051Z + action: APPROVE_ARTIFACT + feature: user-preferences + actor: user + result: success + - timestamp: 2026-02-08T01:54:48.465771302Z + action: APPROVE_ARTIFACT + feature: user-preferences + actor: user + result: success + - timestamp: 2026-02-08T01:55:28.532343807Z + action: TRANSITION + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T01:55:40.126818751Z + action: APPROVE_ARTIFACT + feature: user-preferences + actor: user + result: success + - timestamp: 2026-02-08T01:55:40.135956827Z + action: TRANSITION + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T01:55:43.597311998Z + action: TRANSITION + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T01:55:43.601270336Z + action: TRANSITION + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T01:56:04.694872167Z + action: COMPLETE_TASK + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T01:56:55.635906067Z + action: COMPLETE_TASK + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T01:57:11.98799057Z + action: COMPLETE_TASK + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T01:57:36.506965158Z + action: COMPLETE_TASK + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T01:58:17.27910154Z + action: COMPLETE_TASK + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T02:00:03.08338958Z + action: COMPLETE_TASK + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T02:00:55.561956623Z + action: COMPLETE_TASK + feature: user-preferences + actor: cli + result: success + - timestamp: 2026-02-08T02:01:30.334180365Z + action: COMPLETE_TASK + feature: user-preferences + actor: cli + result: success diff --git a/services/preferences-api/cmd/server/main.go b/services/preferences-api/cmd/server/main.go index 1be4f1f..08a3f94 100644 --- a/services/preferences-api/cmd/server/main.go +++ b/services/preferences-api/cmd/server/main.go @@ -14,11 +14,9 @@ import ( ) func main() { - // Parse flags exportOpenAPI := flag.Bool("export-openapi", false, "Export OpenAPI spec to stdout and exit") flag.Parse() - // If exporting OpenAPI, generate spec and exit (used by CI for docs generation) if *exportOpenAPI { spec := api.NewServiceSpec() jsonBytes, err := spec.JSON() @@ -30,21 +28,12 @@ func main() { os.Exit(0) } - // Create logger logger := logging.Default() - // Create adapters (repositories) - exampleRepo := memory.NewExampleRepository() + preferencesRepo := memory.NewPreferencesRepository() + preferencesService := service.NewPreferencesService(preferencesRepo, logger) - // Create services (business logic) - exampleService := service.NewExampleService(exampleRepo, logger) - - // Create application application := app.New("preferences-api", app.WithDefaultPort(8001)) - - // Register routes with dependency injection - api.RegisterRoutes(application, exampleService) - - // Start server + api.RegisterRoutes(application, preferencesService) application.Run() } diff --git a/services/preferences-api/internal/adapter/memory/example.go b/services/preferences-api/internal/adapter/memory/example.go deleted file mode 100644 index eed3659..0000000 --- a/services/preferences-api/internal/adapter/memory/example.go +++ /dev/null @@ -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/slate-v3-1770514618/services/preferences-api/internal/domain" - "git.threesix.ai/jordan/slate-v3-1770514618/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 ©, 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] = © - 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] = © - 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 -} diff --git a/services/preferences-api/internal/adapter/memory/preferences.go b/services/preferences-api/internal/adapter/memory/preferences.go new file mode 100644 index 0000000..84543b4 --- /dev/null +++ b/services/preferences-api/internal/adapter/memory/preferences.go @@ -0,0 +1,50 @@ +package memory + +import ( + "context" + "sync" + + "git.threesix.ai/jordan/slate-v3-1770514618/services/preferences-api/internal/domain" + "git.threesix.ai/jordan/slate-v3-1770514618/services/preferences-api/internal/port" +) + +var _ port.PreferencesRepository = (*PreferencesRepository)(nil) + +// PreferencesRepository is a thread-safe in-memory implementation of port.PreferencesRepository. +type PreferencesRepository struct { + mu sync.RWMutex + prefs map[domain.UserID]*domain.Preferences +} + +// NewPreferencesRepository creates a new in-memory preferences repository. +func NewPreferencesRepository() *PreferencesRepository { + return &PreferencesRepository{ + prefs: make(map[domain.UserID]*domain.Preferences), + } +} + +// Get returns preferences for a user. +// Returns domain.ErrPreferencesNotFound if not found. +func (r *PreferencesRepository) Get(_ context.Context, userID domain.UserID) (*domain.Preferences, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + p, ok := r.prefs[userID] + if !ok { + return nil, domain.ErrPreferencesNotFound + } + cp := *p + cp.Notifications = p.Notifications + return &cp, nil +} + +// Upsert stores preferences for a user (insert or replace). +func (r *PreferencesRepository) Upsert(_ context.Context, userID domain.UserID, prefs *domain.Preferences) error { + r.mu.Lock() + defer r.mu.Unlock() + + cp := *prefs + cp.Notifications = prefs.Notifications + r.prefs[userID] = &cp + return nil +} diff --git a/services/preferences-api/internal/adapter/memory/preferences_test.go b/services/preferences-api/internal/adapter/memory/preferences_test.go new file mode 100644 index 0000000..ded662a --- /dev/null +++ b/services/preferences-api/internal/adapter/memory/preferences_test.go @@ -0,0 +1,73 @@ +package memory + +import ( + "context" + "testing" + + "git.threesix.ai/jordan/slate-v3-1770514618/services/preferences-api/internal/domain" +) + +func TestPreferencesRepository_GetMissing(t *testing.T) { + repo := NewPreferencesRepository() + _, err := repo.Get(context.Background(), "nonexistent") + if err != domain.ErrPreferencesNotFound { + t.Errorf("expected ErrPreferencesNotFound, got %v", err) + } +} + +func TestPreferencesRepository_UpsertAndGet(t *testing.T) { + repo := NewPreferencesRepository() + ctx := context.Background() + + prefs := domain.NewDefaultPreferences("user-1") + prefs.Theme = "dark" + + if err := repo.Upsert(ctx, "user-1", prefs); err != nil { + t.Fatalf("unexpected error on upsert: %v", err) + } + + got, err := repo.Get(ctx, "user-1") + if err != nil { + t.Fatalf("unexpected error on get: %v", err) + } + if got.Theme != "dark" { + t.Errorf("expected theme 'dark', got '%s'", got.Theme) + } + if got.UserID != "user-1" { + t.Errorf("expected user_id 'user-1', got '%s'", got.UserID) + } +} + +func TestPreferencesRepository_UpsertOverwrites(t *testing.T) { + repo := NewPreferencesRepository() + ctx := context.Background() + + prefs1 := domain.NewDefaultPreferences("user-1") + prefs1.Theme = "dark" + _ = repo.Upsert(ctx, "user-1", prefs1) + + prefs2 := domain.NewDefaultPreferences("user-1") + prefs2.Theme = "light" + _ = repo.Upsert(ctx, "user-1", prefs2) + + got, _ := repo.Get(ctx, "user-1") + if got.Theme != "light" { + t.Errorf("expected theme 'light' after overwrite, got '%s'", got.Theme) + } +} + +func TestPreferencesRepository_ReturnsCopy(t *testing.T) { + repo := NewPreferencesRepository() + ctx := context.Background() + + prefs := domain.NewDefaultPreferences("user-1") + _ = repo.Upsert(ctx, "user-1", prefs) + + got, _ := repo.Get(ctx, "user-1") + got.Theme = "modified" + + got2, _ := repo.Get(ctx, "user-1") + if got2.Theme != "system" { + t.Errorf("expected stored theme 'system' unchanged, got '%s'", got2.Theme) + } +} diff --git a/services/preferences-api/internal/api/handlers/example.go b/services/preferences-api/internal/api/handlers/example.go deleted file mode 100644 index 3968c49..0000000 --- a/services/preferences-api/internal/api/handlers/example.go +++ /dev/null @@ -1,170 +0,0 @@ -package handlers - -import ( - "errors" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - - "git.threesix.ai/jordan/slate-v3-1770514618/pkg/app" - "git.threesix.ai/jordan/slate-v3-1770514618/pkg/httperror" - "git.threesix.ai/jordan/slate-v3-1770514618/pkg/httpresponse" - "git.threesix.ai/jordan/slate-v3-1770514618/pkg/logging" - "git.threesix.ai/jordan/slate-v3-1770514618/services/preferences-api/internal/domain" - "git.threesix.ai/jordan/slate-v3-1770514618/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 - } -} diff --git a/services/preferences-api/internal/api/handlers/example_test.go b/services/preferences-api/internal/api/handlers/example_test.go deleted file mode 100644 index a5d6c04..0000000 --- a/services/preferences-api/internal/api/handlers/example_test.go +++ /dev/null @@ -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/slate-v3-1770514618/pkg/logging" - "git.threesix.ai/jordan/slate-v3-1770514618/services/preferences-api/internal/domain" - "git.threesix.ai/jordan/slate-v3-1770514618/services/preferences-api/internal/port" - "git.threesix.ai/jordan/slate-v3-1770514618/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 ©, 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] = © - 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] = © - 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) - } - }) - } -} diff --git a/services/preferences-api/internal/api/handlers/preferences.go b/services/preferences-api/internal/api/handlers/preferences.go new file mode 100644 index 0000000..a77e627 --- /dev/null +++ b/services/preferences-api/internal/api/handlers/preferences.go @@ -0,0 +1,210 @@ +package handlers + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "git.threesix.ai/jordan/slate-v3-1770514618/pkg/auth" + "git.threesix.ai/jordan/slate-v3-1770514618/pkg/httperror" + "git.threesix.ai/jordan/slate-v3-1770514618/pkg/httpresponse" + "git.threesix.ai/jordan/slate-v3-1770514618/services/preferences-api/internal/domain" + "git.threesix.ai/jordan/slate-v3-1770514618/services/preferences-api/internal/service" +) + +// Preferences handles HTTP requests for user preferences. +type Preferences struct { + svc *service.PreferencesService +} + +// NewPreferences creates a new Preferences handler. +func NewPreferences(svc *service.PreferencesService) *Preferences { + return &Preferences{svc: svc} +} + +// UpdatePreferencesRequest is the request body for updating preferences. +type UpdatePreferencesRequest struct { + Preferences *PreferencesPayload `json:"preferences" validate:"required"` +} + +// PreferencesPayload represents the preferences object in the request. +type PreferencesPayload struct { + Theme *string `json:"theme,omitempty"` + Language *string `json:"language,omitempty"` + Notifications *NotificationSettingsPayload `json:"notifications,omitempty"` +} + +// NotificationSettingsPayload represents notification settings in the request. +type NotificationSettingsPayload struct { + Email *bool `json:"email,omitempty"` + Push *bool `json:"push,omitempty"` + Digest *string `json:"digest,omitempty"` +} + +// PreferencesResponse is the response for preferences. +type PreferencesResponse struct { + UserID string `json:"user_id"` + Preferences PreferencesDataResponse `json:"preferences"` + UpdatedAt string `json:"updated_at"` +} + +// PreferencesDataResponse holds the preference values in the response. +type PreferencesDataResponse struct { + Theme string `json:"theme"` + Language string `json:"language"` + Notifications NotificationSettingsResponse `json:"notifications"` +} + +// NotificationSettingsResponse holds notification settings in the response. +type NotificationSettingsResponse struct { + Email bool `json:"email"` + Push bool `json:"push"` + Digest string `json:"digest"` +} + +func toPreferencesResponse(p *domain.Preferences) PreferencesResponse { + return PreferencesResponse{ + UserID: p.UserID.String(), + Preferences: PreferencesDataResponse{ + Theme: p.Theme, + Language: p.Language, + Notifications: NotificationSettingsResponse{ + Email: p.Notifications.Email, + Push: p.Notifications.Push, + Digest: p.Notifications.Digest, + }, + }, + UpdatedAt: p.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } +} + +// 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 := authorizeAccess(r, userID); err != nil { + return err + } + + prefs, err := h.svc.Get(r.Context(), domain.UserID(userID)) + if err != nil { + return mapDomainError(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") + } + + if err := authorizeAccess(r, userID); err != nil { + return err + } + + // Decode to raw map for manual key validation + var raw map[string]json.RawMessage + if err := httpresponse.DecodeJSON(r, &raw); err != nil { + if errors.Is(err, httpresponse.ErrEmptyBody) { + return httperror.BadRequest("request body is required") + } + return httperror.BadRequest("invalid request body") + } + + prefsRaw, ok := raw["preferences"] + if !ok || string(prefsRaw) == "null" { + return httperror.BadRequest("preferences field is required") + } + + // Check for unknown top-level keys in request body + for key := range raw { + if key != "preferences" { + return httperror.BadRequest(fmt.Sprintf("unknown field: %s", key)) + } + } + + // Parse preferences object and check for unknown keys + var prefsMap map[string]json.RawMessage + if err := json.Unmarshal(prefsRaw, &prefsMap); err != nil { + return httperror.BadRequest("preferences must be a JSON object") + } + + allowedKeys := map[string]bool{"theme": true, "language": true, "notifications": true} + for key := range prefsMap { + if !allowedKeys[key] { + return httperror.BadRequest(fmt.Sprintf("unknown preference key: %s", key)) + } + } + + // Parse the preferences payload + var payload PreferencesPayload + if err := json.Unmarshal(prefsRaw, &payload); err != nil { + return httperror.BadRequest("invalid preferences format") + } + + update := toDomainUpdate(&payload) + + prefs, err := h.svc.Upsert(r.Context(), domain.UserID(userID), update) + if err != nil { + return mapDomainError(err) + } + + httpresponse.OK(w, r, toPreferencesResponse(prefs)) + return nil +} + +func toDomainUpdate(p *PreferencesPayload) *domain.PreferencesUpdate { + update := &domain.PreferencesUpdate{ + Theme: p.Theme, + Language: p.Language, + } + if p.Notifications != nil { + update.Notifications = &domain.NotificationSettingsUpdate{ + Email: p.Notifications.Email, + Push: p.Notifications.Push, + Digest: p.Notifications.Digest, + } + } + return update +} + +// authorizeAccess checks that the authenticated user can access the given user_id. +func authorizeAccess(r *http.Request, targetUserID string) error { + user := auth.GetUser(r.Context()) + if user == nil { + // No auth context — when auth is disabled, allow access + return nil + } + if user.ID == targetUserID || user.HasRole("admin") { + return nil + } + return httperror.Forbidden("access denied") +} + +func mapDomainError(err error) error { + switch { + case errors.Is(err, domain.ErrPreferencesNotFound): + return httperror.NotFound("preferences not found") + case errors.Is(err, domain.ErrInvalidTheme): + return httperror.BadRequest("invalid theme: must be one of light, dark, system") + case errors.Is(err, domain.ErrInvalidLanguage): + return httperror.BadRequest("invalid language: must be a 2-letter lowercase ISO 639-1 code") + case errors.Is(err, domain.ErrInvalidDigest): + return httperror.BadRequest("invalid digest: must be one of daily, weekly, never") + case errors.Is(err, domain.ErrInvalidPreferences): + return httperror.BadRequest("invalid preferences") + default: + return err + } +} diff --git a/services/preferences-api/internal/api/handlers/preferences_test.go b/services/preferences-api/internal/api/handlers/preferences_test.go new file mode 100644 index 0000000..2babde9 --- /dev/null +++ b/services/preferences-api/internal/api/handlers/preferences_test.go @@ -0,0 +1,303 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "git.threesix.ai/jordan/slate-v3-1770514618/pkg/app" + "git.threesix.ai/jordan/slate-v3-1770514618/pkg/auth" + "git.threesix.ai/jordan/slate-v3-1770514618/pkg/logging" + "git.threesix.ai/jordan/slate-v3-1770514618/services/preferences-api/internal/adapter/memory" + "git.threesix.ai/jordan/slate-v3-1770514618/services/preferences-api/internal/service" +) + +func newTestPreferencesHandler() *Preferences { + repo := memory.NewPreferencesRepository() + svc := service.NewPreferencesService(repo, logging.Nop()) + return NewPreferences(svc) +} + +func withAuthUser(r *http.Request, userID string, roles ...string) *http.Request { + user := &auth.User{ID: userID, Roles: roles} + ctx := auth.SetUser(r.Context(), user) + return r.WithContext(ctx) +} + +func setupRouter(handler *Preferences) *chi.Mux { + r := chi.NewRouter() + r.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Get)) + r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Update)) + return r +} + +func TestPreferences_Get_NotFound(t *testing.T) { + handler := newTestPreferencesHandler() + router := setupRouter(handler) + + req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } +} + +func TestPreferences_Get_InvalidUUID(t *testing.T) { + handler := newTestPreferencesHandler() + router := setupRouter(handler) + + req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/not-a-uuid", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestPreferences_Get_Forbidden(t *testing.T) { + handler := newTestPreferencesHandler() + + r := chi.NewRouter() + r.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Get)) + + req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000", nil) + req = withAuthUser(req, "different-user-id") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403, got %d", w.Code) + } +} + +func TestPreferences_Get_AdminAccess(t *testing.T) { + handler := newTestPreferencesHandler() + + // First create preferences + r := chi.NewRouter() + r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Update)) + r.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Get)) + + body := `{"preferences":{"theme":"dark"}}` + putReq := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000", bytes.NewBufferString(body)) + putReq.Header.Set("Content-Type", "application/json") + putReq = withAuthUser(putReq, "550e8400-e29b-41d4-a716-446655440000") + putW := httptest.NewRecorder() + r.ServeHTTP(putW, putReq) + + // Admin accesses another user's prefs + getReq := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000", nil) + getReq = withAuthUser(getReq, "admin-user-id", "admin") + getW := httptest.NewRecorder() + r.ServeHTTP(getW, getReq) + + if getW.Code != http.StatusOK { + t.Errorf("expected 200 for admin access, got %d", getW.Code) + } +} + +func TestPreferences_Update_CreateNew(t *testing.T) { + handler := newTestPreferencesHandler() + router := setupRouter(handler) + + body := `{"preferences":{"theme":"dark","language":"fr"}}` + req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d; body: %s", w.Code, w.Body.String()) + } + + 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 in response") + } + + prefs, ok := data["preferences"].(map[string]any) + if !ok { + t.Fatal("expected 'preferences' field in data") + } + + 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"]) + } +} + +func TestPreferences_Update_MergeExisting(t *testing.T) { + handler := newTestPreferencesHandler() + router := setupRouter(handler) + userID := "550e8400-e29b-41d4-a716-446655440000" + + // Create initial + body1 := `{"preferences":{"theme":"dark","language":"fr"}}` + req1 := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+userID, bytes.NewBufferString(body1)) + req1.Header.Set("Content-Type", "application/json") + w1 := httptest.NewRecorder() + router.ServeHTTP(w1, req1) + + // Update only theme + body2 := `{"preferences":{"theme":"light"}}` + req2 := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+userID, bytes.NewBufferString(body2)) + req2.Header.Set("Content-Type", "application/json") + w2 := httptest.NewRecorder() + router.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("expected 200, got %d; body: %s", w2.Code, w2.Body.String()) + } + + var resp map[string]any + _ = json.NewDecoder(w2.Body).Decode(&resp) + data := resp["data"].(map[string]any) + prefs := data["preferences"].(map[string]any) + + if prefs["theme"] != "light" { + t.Errorf("expected theme 'light', got %v", prefs["theme"]) + } + if prefs["language"] != "fr" { + t.Errorf("expected language 'fr' preserved, got %v", prefs["language"]) + } +} + +func TestPreferences_Update_InvalidTheme(t *testing.T) { + handler := newTestPreferencesHandler() + router := setupRouter(handler) + + body := `{"preferences":{"theme":"blue"}}` + req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestPreferences_Update_UnknownPreferenceKey(t *testing.T) { + handler := newTestPreferencesHandler() + router := setupRouter(handler) + + body := `{"preferences":{"theme":"dark","unknown_key":"value"}}` + req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for unknown preference key, got %d; body: %s", w.Code, w.Body.String()) + } +} + +func TestPreferences_Update_MissingPreferencesField(t *testing.T) { + handler := newTestPreferencesHandler() + router := setupRouter(handler) + + body := `{"theme":"dark"}` + req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for missing preferences field, got %d; body: %s", w.Code, w.Body.String()) + } +} + +func TestPreferences_Update_EmptyBody(t *testing.T) { + handler := newTestPreferencesHandler() + router := setupRouter(handler) + + req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000", nil) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for empty body, got %d", w.Code) + } +} + +func TestPreferences_Update_Forbidden(t *testing.T) { + handler := newTestPreferencesHandler() + + r := chi.NewRouter() + r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Update)) + + body := `{"preferences":{"theme":"dark"}}` + req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req = withAuthUser(req, "different-user-id") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403, got %d", w.Code) + } +} + +func TestPreferences_Update_InvalidUUID(t *testing.T) { + handler := newTestPreferencesHandler() + router := setupRouter(handler) + + body := `{"preferences":{"theme":"dark"}}` + req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/not-valid", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestPreferences_GetAfterUpdate(t *testing.T) { + handler := newTestPreferencesHandler() + router := setupRouter(handler) + userID := "550e8400-e29b-41d4-a716-446655440000" + + // Create via PUT + body := `{"preferences":{"theme":"dark"}}` + putReq := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+userID, bytes.NewBufferString(body)) + putReq.Header.Set("Content-Type", "application/json") + putW := httptest.NewRecorder() + router.ServeHTTP(putW, putReq) + + // GET + getReq := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/"+userID, nil) + getW := httptest.NewRecorder() + router.ServeHTTP(getW, getReq) + + if getW.Code != http.StatusOK { + t.Fatalf("expected 200, got %d; body: %s", getW.Code, getW.Body.String()) + } + + var resp map[string]any + _ = json.NewDecoder(getW.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"] != "en" { + t.Errorf("expected default language 'en', got %v", prefs["language"]) + } +} diff --git a/services/preferences-api/internal/api/routes.go b/services/preferences-api/internal/api/routes.go index ac07362..285d528 100644 --- a/services/preferences-api/internal/api/routes.go +++ b/services/preferences-api/internal/api/routes.go @@ -11,30 +11,19 @@ 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) + preferencesHandler := handlers.NewPreferences(preferencesService) - // 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 +35,8 @@ 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)) + r.Get("/preferences/{user_id}", app.Wrap(preferencesHandler.Get)) + r.Put("/preferences/{user_id}", app.Wrap(preferencesHandler.Update)) }) }) } diff --git a/services/preferences-api/internal/api/spec.go b/services/preferences-api/internal/api/spec.go index d875cc0..84c274f 100644 --- a/services/preferences-api/internal/api/spec.go +++ b/services/preferences-api/internal/api/spec.go @@ -5,30 +5,42 @@ import "git.threesix.ai/jordan/slate-v3-1770514618/pkg/openapi" // NewServiceSpec builds the OpenAPI specification for the preferences-api service. func NewServiceSpec() *openapi.OpenAPISpec { 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"). 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("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"), + // Schemas + spec.WithSchema("NotificationSettings", openapi.Object(map[string]openapi.Schema{ + "email": openapi.Bool().WithDescription("Email notifications enabled"), + "push": openapi.Bool().WithDescription("Push notifications enabled"), + "digest": openapi.StringEnum("daily", "weekly", "never").WithDescription("Digest frequency"), })) + spec.WithSchema("Preferences", 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", "system").WithDescription("UI theme"), + "language": openapi.String().WithDescription("ISO 639-1 language code").WithPattern("^[a-z]{2}$").WithExample("en"), + "notifications": openapi.Ref("NotificationSettings"), + }), + "updated_at": openapi.DateTime().WithDescription("Last update timestamp"), + }, "user_id", "preferences", "updated_at")) + + spec.WithSchema("UpdatePreferencesRequest", openapi.Object(map[string]openapi.Schema{ + "preferences": openapi.Object(map[string]openapi.Schema{ + "theme": openapi.StringEnum("light", "dark", "system").WithDescription("UI theme"), + "language": openapi.String().WithDescription("ISO 639-1 language code").WithPattern("^[a-z]{2}$"), + "notifications": openapi.Object(map[string]openapi.Schema{ + "email": openapi.Bool().WithDescription("Email notifications enabled"), + "push": openapi.Bool().WithDescription("Push notifications enabled"), + "digest": openapi.StringEnum("daily", "weekly", "never").WithDescription("Digest frequency"), + }), + }), + }, "preferences")) + + userIDParam := openapi.PathParamWithSchema("user_id", "User identifier (UUID)", openapi.UUID()) + // Health spec.AddPath("/api/preferences-api/health", "get", map[string]any{ "summary": "Health check", @@ -41,70 +53,35 @@ 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 preferences + spec.AddPath("/api/preferences-api/preferences/{user_id}", "get", map[string]any{ + "summary": "Get user preferences", + "description": "Returns all preferences for a user. Requires authentication.", + "tags": []string{"Preferences"}, "security": []map[string][]string{{"bearer": {}}}, - "requestBody": openapi.RequestBody(openapi.Ref("CreateExampleRequest"), true), + "parameters": []any{userIDParam}, "responses": map[string]any{ - "201": openapi.OpResponse("Created", openapi.ResponseSchema(openapi.Ref("Example"))), + "200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("Preferences"))), "400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()), "401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()), - "422": openapi.OpResponse("Validation error", openapi.ErrorResponseSchema()), + "403": openapi.OpResponse("Forbidden", openapi.ErrorResponseSchema()), + "404": openapi.OpResponse("Not found", 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 preferences + spec.AddPath("/api/preferences-api/preferences/{user_id}", "put", map[string]any{ + "summary": "Create or update user preferences", + "description": "Creates or updates preferences for a user with merge semantics. Requires authentication.", + "tags": []string{"Preferences"}, "security": []map[string][]string{{"bearer": {}}}, - "parameters": []any{openapi.IDParam()}, - "requestBody": openapi.RequestBody(openapi.Ref("UpdateExampleRequest"), true), + "parameters": []any{userIDParam}, + "requestBody": openapi.RequestBody(openapi.Ref("UpdatePreferencesRequest"), true), "responses": map[string]any{ - "200": openapi.OpResponse("Updated", openapi.ResponseSchema(openapi.Ref("Example"))), + "200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("Preferences"))), "400": openapi.OpResponse("Bad request", 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()), + "403": openapi.OpResponse("Forbidden", openapi.ErrorResponseSchema()), }, }) diff --git a/services/preferences-api/internal/domain/errors.go b/services/preferences-api/internal/domain/errors.go index d4ffe10..b1cf895 100644 --- a/services/preferences-api/internal/domain/errors.go +++ b/services/preferences-api/internal/domain/errors.go @@ -1,21 +1,11 @@ -// Package domain contains pure domain models with no external dependencies. -// These types represent the core business concepts of the service. package domain import "errors" -// Domain errors - these are business-level errors that should be translated -// to appropriate HTTP status codes by the handler layer. 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") - - // 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") + ErrPreferencesNotFound = errors.New("preferences not found") + ErrInvalidTheme = errors.New("invalid theme value") + ErrInvalidLanguage = errors.New("invalid language value") + ErrInvalidDigest = errors.New("invalid digest value") + ErrInvalidPreferences = errors.New("invalid preferences") ) diff --git a/services/preferences-api/internal/domain/example.go b/services/preferences-api/internal/domain/example.go deleted file mode 100644 index 4ee48e9..0000000 --- a/services/preferences-api/internal/domain/example.go +++ /dev/null @@ -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 -} diff --git a/services/preferences-api/internal/domain/preferences.go b/services/preferences-api/internal/domain/preferences.go new file mode 100644 index 0000000..c5dd03a --- /dev/null +++ b/services/preferences-api/internal/domain/preferences.go @@ -0,0 +1,112 @@ +package domain + +import ( + "regexp" + "time" +) + +// UserID is a strongly-typed identifier for users. +type UserID string + +func (id UserID) String() string { return string(id) } +func (id UserID) IsZero() bool { return id == "" } + +// Allowed theme values. +var allowedThemes = map[string]bool{ + "light": true, + "dark": true, + "system": true, +} + +// Allowed digest values. +var allowedDigests = map[string]bool{ + "daily": true, + "weekly": true, + "never": true, +} + +var languageRegex = regexp.MustCompile(`^[a-z]{2}$`) + +// NotificationSettings holds notification preferences. +type NotificationSettings struct { + Email bool + Push bool + Digest string +} + +// Preferences holds all user preferences. +type Preferences struct { + UserID UserID + Theme string + Language string + Notifications NotificationSettings + UpdatedAt time.Time +} + +// NewDefaultPreferences returns preferences with all defaults applied. +func NewDefaultPreferences(userID UserID) *Preferences { + return &Preferences{ + UserID: userID, + Theme: "system", + Language: "en", + Notifications: NotificationSettings{ + Email: true, + Push: true, + Digest: "weekly", + }, + UpdatedAt: time.Now().UTC(), + } +} + +// Validate checks that all field values are within allowed sets. +func (p *Preferences) Validate() error { + if !allowedThemes[p.Theme] { + return ErrInvalidTheme + } + if !languageRegex.MatchString(p.Language) { + return ErrInvalidLanguage + } + if !allowedDigests[p.Notifications.Digest] { + return ErrInvalidDigest + } + return nil +} + +// NotificationSettingsUpdate uses pointer fields to distinguish provided vs absent. +type NotificationSettingsUpdate struct { + Email *bool + Push *bool + Digest *string +} + +// PreferencesUpdate uses pointer fields to distinguish provided vs absent. +type PreferencesUpdate struct { + Theme *string + Language *string + Notifications *NotificationSettingsUpdate +} + +// MergeFrom applies a shallow merge: only overwrites fields where the update pointer is non-nil. +// For Notifications, individual sub-fields are merged when provided. +func (p *Preferences) MergeFrom(incoming *PreferencesUpdate) { + if incoming == nil { + return + } + if incoming.Theme != nil { + p.Theme = *incoming.Theme + } + if incoming.Language != nil { + p.Language = *incoming.Language + } + if incoming.Notifications != nil { + if incoming.Notifications.Email != nil { + p.Notifications.Email = *incoming.Notifications.Email + } + if incoming.Notifications.Push != nil { + p.Notifications.Push = *incoming.Notifications.Push + } + if incoming.Notifications.Digest != nil { + p.Notifications.Digest = *incoming.Notifications.Digest + } + } +} diff --git a/services/preferences-api/internal/domain/preferences_test.go b/services/preferences-api/internal/domain/preferences_test.go new file mode 100644 index 0000000..c02d37c --- /dev/null +++ b/services/preferences-api/internal/domain/preferences_test.go @@ -0,0 +1,211 @@ +package domain + +import ( + "testing" +) + +func TestNewDefaultPreferences(t *testing.T) { + p := NewDefaultPreferences("user-123") + + if p.UserID != "user-123" { + t.Errorf("expected UserID 'user-123', got '%s'", p.UserID) + } + if p.Theme != "system" { + t.Errorf("expected theme 'system', got '%s'", p.Theme) + } + if p.Language != "en" { + t.Errorf("expected language 'en', got '%s'", p.Language) + } + if !p.Notifications.Email { + t.Error("expected email=true") + } + if !p.Notifications.Push { + t.Error("expected push=true") + } + if p.Notifications.Digest != "weekly" { + t.Errorf("expected digest 'weekly', got '%s'", p.Notifications.Digest) + } + if p.UpdatedAt.IsZero() { + t.Error("expected non-zero UpdatedAt") + } +} + +func TestPreferences_Validate(t *testing.T) { + tests := []struct { + name string + modify func(p *Preferences) + wantErr error + }{ + { + name: "valid defaults", + modify: func(p *Preferences) {}, + wantErr: nil, + }, + { + name: "valid light theme", + modify: func(p *Preferences) { p.Theme = "light" }, + wantErr: nil, + }, + { + name: "valid dark theme", + modify: func(p *Preferences) { p.Theme = "dark" }, + wantErr: nil, + }, + { + name: "invalid theme", + modify: func(p *Preferences) { p.Theme = "blue" }, + wantErr: ErrInvalidTheme, + }, + { + name: "empty theme", + modify: func(p *Preferences) { p.Theme = "" }, + wantErr: ErrInvalidTheme, + }, + { + name: "valid language es", + modify: func(p *Preferences) { p.Language = "es" }, + wantErr: nil, + }, + { + name: "invalid language - too long", + modify: func(p *Preferences) { p.Language = "eng" }, + wantErr: ErrInvalidLanguage, + }, + { + name: "invalid language - uppercase", + modify: func(p *Preferences) { p.Language = "EN" }, + wantErr: ErrInvalidLanguage, + }, + { + name: "invalid language - empty", + modify: func(p *Preferences) { p.Language = "" }, + wantErr: ErrInvalidLanguage, + }, + { + name: "valid digest daily", + modify: func(p *Preferences) { p.Notifications.Digest = "daily" }, + wantErr: nil, + }, + { + name: "valid digest never", + modify: func(p *Preferences) { p.Notifications.Digest = "never" }, + wantErr: nil, + }, + { + name: "invalid digest", + modify: func(p *Preferences) { p.Notifications.Digest = "monthly" }, + wantErr: ErrInvalidDigest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := NewDefaultPreferences("user-1") + tt.modify(p) + err := p.Validate() + if err != tt.wantErr { + t.Errorf("expected error %v, got %v", tt.wantErr, err) + } + }) + } +} + +func TestPreferences_MergeFrom(t *testing.T) { + strPtr := func(s string) *string { return &s } + boolPtr := func(b bool) *bool { return &b } + + t.Run("nil update does nothing", func(t *testing.T) { + p := NewDefaultPreferences("user-1") + p.MergeFrom(nil) + if p.Theme != "system" { + t.Errorf("expected theme 'system', got '%s'", p.Theme) + } + }) + + t.Run("partial update - theme only", func(t *testing.T) { + p := NewDefaultPreferences("user-1") + p.MergeFrom(&PreferencesUpdate{ + Theme: strPtr("dark"), + }) + if p.Theme != "dark" { + t.Errorf("expected theme 'dark', got '%s'", p.Theme) + } + if p.Language != "en" { + t.Errorf("expected language 'en' unchanged, got '%s'", p.Language) + } + }) + + t.Run("partial update - language only", func(t *testing.T) { + p := NewDefaultPreferences("user-1") + p.MergeFrom(&PreferencesUpdate{ + Language: strPtr("fr"), + }) + if p.Language != "fr" { + t.Errorf("expected language 'fr', got '%s'", p.Language) + } + if p.Theme != "system" { + t.Errorf("expected theme 'system' unchanged, got '%s'", p.Theme) + } + }) + + t.Run("notifications sub-field merge", func(t *testing.T) { + p := NewDefaultPreferences("user-1") + p.MergeFrom(&PreferencesUpdate{ + Notifications: &NotificationSettingsUpdate{ + Email: boolPtr(false), + }, + }) + if p.Notifications.Email != false { + t.Error("expected email=false") + } + if p.Notifications.Push != true { + t.Error("expected push=true unchanged") + } + if p.Notifications.Digest != "weekly" { + t.Errorf("expected digest 'weekly' unchanged, got '%s'", p.Notifications.Digest) + } + }) + + t.Run("full update", func(t *testing.T) { + p := NewDefaultPreferences("user-1") + p.MergeFrom(&PreferencesUpdate{ + Theme: strPtr("light"), + Language: strPtr("es"), + Notifications: &NotificationSettingsUpdate{ + Email: boolPtr(false), + Push: boolPtr(false), + Digest: strPtr("daily"), + }, + }) + if p.Theme != "light" { + t.Errorf("expected theme 'light', got '%s'", p.Theme) + } + if p.Language != "es" { + t.Errorf("expected language 'es', got '%s'", p.Language) + } + if p.Notifications.Email != false { + t.Error("expected email=false") + } + if p.Notifications.Push != false { + t.Error("expected push=false") + } + if p.Notifications.Digest != "daily" { + t.Errorf("expected digest 'daily', got '%s'", p.Notifications.Digest) + } + }) +} + +func TestUserID(t *testing.T) { + id := UserID("test-123") + if id.String() != "test-123" { + t.Errorf("expected 'test-123', got '%s'", id.String()) + } + if id.IsZero() { + t.Error("expected non-zero") + } + + var empty UserID + if !empty.IsZero() { + t.Error("expected zero") + } +} diff --git a/services/preferences-api/internal/port/example.go b/services/preferences-api/internal/port/example.go deleted file mode 100644 index a6aafc0..0000000 --- a/services/preferences-api/internal/port/example.go +++ /dev/null @@ -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/slate-v3-1770514618/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) -} diff --git a/services/preferences-api/internal/port/preferences.go b/services/preferences-api/internal/port/preferences.go new file mode 100644 index 0000000..626dcde --- /dev/null +++ b/services/preferences-api/internal/port/preferences.go @@ -0,0 +1,17 @@ +package port + +import ( + "context" + + "git.threesix.ai/jordan/slate-v3-1770514618/services/preferences-api/internal/domain" +) + +// PreferencesRepository defines the interface for preferences persistence operations. +type PreferencesRepository interface { + // Get returns preferences for a user. + // Returns domain.ErrPreferencesNotFound if not found. + Get(ctx context.Context, userID domain.UserID) (*domain.Preferences, error) + + // Upsert stores preferences for a user (insert or replace). + Upsert(ctx context.Context, userID domain.UserID, prefs *domain.Preferences) error +} diff --git a/services/preferences-api/internal/service/example.go b/services/preferences-api/internal/service/example.go deleted file mode 100644 index ca8decc..0000000 --- a/services/preferences-api/internal/service/example.go +++ /dev/null @@ -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/slate-v3-1770514618/pkg/logging" - "git.threesix.ai/jordan/slate-v3-1770514618/services/preferences-api/internal/domain" - "git.threesix.ai/jordan/slate-v3-1770514618/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 -} diff --git a/services/preferences-api/internal/service/example_test.go b/services/preferences-api/internal/service/example_test.go deleted file mode 100644 index 910c992..0000000 --- a/services/preferences-api/internal/service/example_test.go +++ /dev/null @@ -1,282 +0,0 @@ -package service - -import ( - "context" - "sync" - "testing" - - "git.threesix.ai/jordan/slate-v3-1770514618/pkg/logging" - "git.threesix.ai/jordan/slate-v3-1770514618/services/preferences-api/internal/domain" - "git.threesix.ai/jordan/slate-v3-1770514618/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 ©, 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] = © - 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] = © - 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)) - } - }) -} diff --git a/services/preferences-api/internal/service/preferences.go b/services/preferences-api/internal/service/preferences.go new file mode 100644 index 0000000..fa95f57 --- /dev/null +++ b/services/preferences-api/internal/service/preferences.go @@ -0,0 +1,59 @@ +package service + +import ( + "context" + "errors" + "time" + + "git.threesix.ai/jordan/slate-v3-1770514618/pkg/logging" + "git.threesix.ai/jordan/slate-v3-1770514618/services/preferences-api/internal/domain" + "git.threesix.ai/jordan/slate-v3-1770514618/services/preferences-api/internal/port" +) + +// PreferencesService handles preferences-related 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 domain.ErrPreferencesNotFound if not found. +func (s *PreferencesService) Get(ctx context.Context, userID domain.UserID) (*domain.Preferences, error) { + return s.repo.Get(ctx, userID) +} + +// Upsert creates or updates preferences for a user with merge semantics. +// If no preferences exist, creates defaults and merges the update. +// Validates the merged result before persisting. +func (s *PreferencesService) Upsert(ctx context.Context, userID domain.UserID, update *domain.PreferencesUpdate) (*domain.Preferences, error) { + prefs, err := s.repo.Get(ctx, userID) + if err != nil { + if !errors.Is(err, domain.ErrPreferencesNotFound) { + return nil, err + } + prefs = domain.NewDefaultPreferences(userID) + } + + prefs.MergeFrom(update) + + if err := prefs.Validate(); err != nil { + return nil, err + } + + prefs.UpdatedAt = time.Now().UTC() + + if err := s.repo.Upsert(ctx, userID, prefs); err != nil { + return nil, err + } + + s.logger.Info("preferences upserted", "user_id", userID.String()) + return prefs, nil +} diff --git a/services/preferences-api/internal/service/preferences_test.go b/services/preferences-api/internal/service/preferences_test.go new file mode 100644 index 0000000..22936e5 --- /dev/null +++ b/services/preferences-api/internal/service/preferences_test.go @@ -0,0 +1,153 @@ +package service + +import ( + "context" + "testing" + + "git.threesix.ai/jordan/slate-v3-1770514618/pkg/logging" + "git.threesix.ai/jordan/slate-v3-1770514618/services/preferences-api/internal/adapter/memory" + "git.threesix.ai/jordan/slate-v3-1770514618/services/preferences-api/internal/domain" +) + +func strPtr(s string) *string { return &s } +func boolPtr(b bool) *bool { return &b } + +func TestPreferencesService_Get(t *testing.T) { + repo := memory.NewPreferencesRepository() + svc := NewPreferencesService(repo, logging.Nop()) + ctx := context.Background() + + t.Run("returns not found when no preferences exist", func(t *testing.T) { + _, err := svc.Get(ctx, "user-1") + if err != domain.ErrPreferencesNotFound { + t.Errorf("expected ErrPreferencesNotFound, got %v", err) + } + }) + + t.Run("returns preferences after upsert", func(t *testing.T) { + _, _ = svc.Upsert(ctx, "user-1", &domain.PreferencesUpdate{ + Theme: strPtr("dark"), + }) + prefs, err := svc.Get(ctx, "user-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if prefs.Theme != "dark" { + t.Errorf("expected theme 'dark', got '%s'", prefs.Theme) + } + }) +} + +func TestPreferencesService_Upsert(t *testing.T) { + repo := memory.NewPreferencesRepository() + svc := NewPreferencesService(repo, logging.Nop()) + ctx := context.Background() + + t.Run("creates new with defaults and merges", func(t *testing.T) { + prefs, err := svc.Upsert(ctx, "user-new", &domain.PreferencesUpdate{ + Theme: strPtr("light"), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if prefs.Theme != "light" { + t.Errorf("expected theme 'light', got '%s'", prefs.Theme) + } + if prefs.Language != "en" { + t.Errorf("expected default language 'en', got '%s'", prefs.Language) + } + if !prefs.Notifications.Email { + t.Error("expected default email=true") + } + }) + + t.Run("updates existing with merge", func(t *testing.T) { + // First upsert + _, _ = svc.Upsert(ctx, "user-merge", &domain.PreferencesUpdate{ + Theme: strPtr("dark"), + Language: strPtr("fr"), + }) + + // Second upsert - only change language + prefs, err := svc.Upsert(ctx, "user-merge", &domain.PreferencesUpdate{ + Language: strPtr("es"), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if prefs.Theme != "dark" { + t.Errorf("expected theme 'dark' unchanged, got '%s'", prefs.Theme) + } + if prefs.Language != "es" { + t.Errorf("expected language 'es', got '%s'", prefs.Language) + } + }) + + t.Run("rejects invalid theme", func(t *testing.T) { + _, err := svc.Upsert(ctx, "user-invalid", &domain.PreferencesUpdate{ + Theme: strPtr("blue"), + }) + if err != domain.ErrInvalidTheme { + t.Errorf("expected ErrInvalidTheme, got %v", err) + } + }) + + t.Run("rejects invalid language", func(t *testing.T) { + _, err := svc.Upsert(ctx, "user-invalid2", &domain.PreferencesUpdate{ + Language: strPtr("XYZ"), + }) + if err != domain.ErrInvalidLanguage { + t.Errorf("expected ErrInvalidLanguage, got %v", err) + } + }) + + t.Run("rejects invalid digest", func(t *testing.T) { + _, err := svc.Upsert(ctx, "user-invalid3", &domain.PreferencesUpdate{ + Notifications: &domain.NotificationSettingsUpdate{ + Digest: strPtr("monthly"), + }, + }) + if err != domain.ErrInvalidDigest { + t.Errorf("expected ErrInvalidDigest, got %v", err) + } + }) + + t.Run("sets UpdatedAt", func(t *testing.T) { + prefs, err := svc.Upsert(ctx, "user-time", &domain.PreferencesUpdate{ + Theme: strPtr("dark"), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if prefs.UpdatedAt.IsZero() { + t.Error("expected non-zero UpdatedAt") + } + }) + + t.Run("notification sub-field merge preserves unset fields", func(t *testing.T) { + _, _ = svc.Upsert(ctx, "user-notif", &domain.PreferencesUpdate{ + Notifications: &domain.NotificationSettingsUpdate{ + Email: boolPtr(false), + Push: boolPtr(false), + Digest: strPtr("daily"), + }, + }) + prefs, err := svc.Upsert(ctx, "user-notif", &domain.PreferencesUpdate{ + Notifications: &domain.NotificationSettingsUpdate{ + Email: boolPtr(true), + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !prefs.Notifications.Email { + t.Error("expected email=true") + } + if prefs.Notifications.Push != false { + t.Error("expected push=false preserved") + } + if prefs.Notifications.Digest != "daily" { + t.Errorf("expected digest 'daily' preserved, got '%s'", prefs.Notifications.Digest) + } + }) +}