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

This commit is contained in:
rdev-worker 2026-02-08 02:02:18 +00:00
parent 3331b4e68f
commit 1afe983cd6
22 changed files with 1382 additions and 1356 deletions

View File

@ -1,20 +1,36 @@
slug: user-preferences slug: user-preferences
title: User Preferences API title: User Preferences API
created: 2026-02-08T01:41:06.381540844Z created: 2026-02-08T01:41:06.381540844Z
phase: draft phase: implementation
phase_history: phase_history:
- phase: draft - phase: draft
entered: 2026-02-08T01:41:06.381540844Z 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: artifacts:
audit: audit:
status: pending status: pending
path: audit.md path: audit.md
design: design:
status: draft status: approved
path: design.md path: design.md
approved_by: user
approved_at: 2026-02-08T01:54:48.460583158Z
qa_plan: qa_plan:
status: draft status: approved
path: qa-plan.md path: qa-plan.md
approved_by: user
approved_at: 2026-02-08T01:55:40.125951198Z
qa_results: qa_results:
status: pending status: pending
path: qa-results.md path: qa-results.md
@ -22,34 +38,55 @@ artifacts:
status: pending status: pending
path: review.md path: review.md
spec: spec:
status: draft status: approved
path: spec.md path: spec.md
approved_by: user
approved_at: 2026-02-08T01:54:48.456496388Z
tasks: tasks:
status: draft status: approved
path: tasks.md path: tasks.md
approved_by: user
approved_at: 2026-02-08T01:54:48.465193053Z
total: 8 total: 8
completed: 8
tasks: tasks:
- id: task-001 - id: task-001
title: Remove example scaffold - delete all example entity files 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 - id: task-002
title: Domain layer - preferences entity, validation, merge logic 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 - id: task-003
title: Port interface - PreferencesRepository with Get and Upsert 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 - id: task-004
title: In-memory adapter - thread-safe map implementation 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 - id: task-005
title: Service layer - PreferencesService with Get and Upsert merge logic 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 - id: task-006
title: HTTP handlers - GET and PUT with auth, validation, error mapping 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 - id: task-007
title: Routes and OpenAPI spec - wire endpoints and document API 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 - id: task-008
title: Wire main.go and integration - connect all layers 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

View File

@ -4,10 +4,10 @@ project:
active_work: active_work:
features: features:
- slug: user-preferences - slug: user-preferences
phase: draft phase: implementation
blocked: [] blocked: []
last_updated: 2026-02-08T01:41:06.381901082Z last_updated: 2026-02-08T02:01:30.334181257Z
last_action: CREATE_FEATURE last_action: COMPLETE_TASK
last_actor: cli last_actor: cli
history: history:
- timestamp: 2026-02-08T01:41:06.381900491Z - timestamp: 2026-02-08T01:41:06.381900491Z
@ -15,3 +15,83 @@ history:
feature: user-preferences feature: user-preferences
actor: cli actor: cli
result: success 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

View File

@ -14,11 +14,9 @@ import (
) )
func main() { func main() {
// Parse flags
exportOpenAPI := flag.Bool("export-openapi", false, "Export OpenAPI spec to stdout and exit") exportOpenAPI := flag.Bool("export-openapi", false, "Export OpenAPI spec to stdout and exit")
flag.Parse() flag.Parse()
// If exporting OpenAPI, generate spec and exit (used by CI for docs generation)
if *exportOpenAPI { if *exportOpenAPI {
spec := api.NewServiceSpec() spec := api.NewServiceSpec()
jsonBytes, err := spec.JSON() jsonBytes, err := spec.JSON()
@ -30,21 +28,12 @@ func main() {
os.Exit(0) os.Exit(0)
} }
// Create logger
logger := logging.Default() logger := logging.Default()
// Create adapters (repositories) preferencesRepo := memory.NewPreferencesRepository()
exampleRepo := memory.NewExampleRepository() preferencesService := service.NewPreferencesService(preferencesRepo, logger)
// Create services (business logic)
exampleService := service.NewExampleService(exampleRepo, logger)
// Create application
application := app.New("preferences-api", app.WithDefaultPort(8001)) application := app.New("preferences-api", app.WithDefaultPort(8001))
api.RegisterRoutes(application, preferencesService)
// Register routes with dependency injection
api.RegisterRoutes(application, exampleService)
// Start server
application.Run() application.Run()
} }

View File

@ -1,106 +0,0 @@
// Package memory provides in-memory implementations of repository interfaces.
// Useful for development, testing, and prototyping.
package memory
import (
"context"
"sync"
"git.threesix.ai/jordan/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 &copy, nil
}
// Create stores a new example.
func (r *ExampleRepository) Create(ctx context.Context, example *domain.Example) error {
r.mu.Lock()
defer r.mu.Unlock()
// Store a copy to prevent external mutation
copy := *example
r.examples[example.ID] = &copy
return nil
}
// Update modifies an existing example.
// Returns domain.ErrExampleNotFound if not found.
func (r *ExampleRepository) Update(ctx context.Context, example *domain.Example) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.examples[example.ID]; !ok {
return domain.ErrExampleNotFound
}
// Store a copy to prevent external mutation
copy := *example
r.examples[example.ID] = &copy
return nil
}
// Delete removes an example by ID.
// Returns domain.ErrExampleNotFound if not found.
func (r *ExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.examples[id]; !ok {
return domain.ErrExampleNotFound
}
delete(r.examples, id)
return nil
}
// ExistsByName checks if an example with the given name exists.
func (r *ExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, e := range r.examples {
if e.Name == name {
return true, nil
}
}
return false, nil
}

View File

@ -0,0 +1,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
}

View File

@ -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)
}
}

View File

@ -1,170 +0,0 @@
package handlers
import (
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"git.threesix.ai/jordan/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
}
}

View File

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

View File

@ -0,0 +1,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
}
}

View File

@ -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"])
}
}

View File

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

View File

@ -5,30 +5,42 @@ import "git.threesix.ai/jordan/slate-v3-1770514618/pkg/openapi"
// NewServiceSpec builds the OpenAPI specification for the preferences-api service. // NewServiceSpec builds the OpenAPI specification for the preferences-api service.
func NewServiceSpec() *openapi.OpenAPISpec { func NewServiceSpec() *openapi.OpenAPISpec {
spec := openapi.NewOpenAPISpec("preferences-api API", "1.0.0"). spec := openapi.NewOpenAPISpec("preferences-api API", "1.0.0").
WithDescription("REST API for the preferences-api service"). WithDescription("REST API for managing user preferences").
WithBearerSecurity("bearer", "JWT authentication token"). WithBearerSecurity("bearer", "JWT authentication token").
WithTag("Health", "Service health endpoints"). WithTag("Health", "Service health endpoints").
WithTag("Examples", "Example CRUD endpoints") WithTag("Preferences", "User preferences endpoints")
// Define reusable schemas // Schemas
spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{ spec.WithSchema("NotificationSettings", openapi.Object(map[string]openapi.Schema{
"id": openapi.UUID().WithDescription("Unique identifier"), "email": openapi.Bool().WithDescription("Email notifications enabled"),
"name": openapi.String().WithDescription("Name of the example").WithExample("My Example"), "push": openapi.Bool().WithDescription("Push notifications enabled"),
"description": openapi.String().WithDescription("Optional description").WithExample("A description"), "digest": openapi.StringEnum("daily", "weekly", "never").WithDescription("Digest frequency"),
"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"),
})) }))
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 // Health
spec.AddPath("/api/preferences-api/health", "get", map[string]any{ spec.AddPath("/api/preferences-api/health", "get", map[string]any{
"summary": "Health check", "summary": "Health check",
@ -41,70 +53,35 @@ func NewServiceSpec() *openapi.OpenAPISpec {
}, },
}) })
// List examples // Get preferences
spec.AddPath("/api/preferences-api/examples", "get", map[string]any{ spec.AddPath("/api/preferences-api/preferences/{user_id}", "get", map[string]any{
"summary": "List examples", "summary": "Get user preferences",
"description": "Returns a paginated list of examples.", "description": "Returns all preferences for a user. Requires authentication.",
"tags": []string{"Examples"}, "tags": []string{"Preferences"},
"parameters": []any{openapi.PageParam(), openapi.PerPageParam()},
"responses": map[string]any{
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.RefArray("Example"))),
},
})
// Get example
spec.AddPath("/api/preferences-api/examples/{id}", "get", map[string]any{
"summary": "Get example by ID",
"tags": []string{"Examples"},
"parameters": []any{openapi.IDParam()},
"responses": map[string]any{
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("Example"))),
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
},
})
// Create example
spec.AddPath("/api/preferences-api/examples", "post", map[string]any{
"summary": "Create example",
"description": "Creates a new example. Requires authentication.",
"tags": []string{"Examples"},
"security": []map[string][]string{{"bearer": {}}}, "security": []map[string][]string{{"bearer": {}}},
"requestBody": openapi.RequestBody(openapi.Ref("CreateExampleRequest"), true), "parameters": []any{userIDParam},
"responses": map[string]any{ "responses": map[string]any{
"201": openapi.OpResponse("Created", openapi.ResponseSchema(openapi.Ref("Example"))), "200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("Preferences"))),
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()), "400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
"401": openapi.OpResponse("Unauthorized", 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 // Update preferences
spec.AddPath("/api/preferences-api/examples/{id}", "put", map[string]any{ spec.AddPath("/api/preferences-api/preferences/{user_id}", "put", map[string]any{
"summary": "Update example", "summary": "Create or update user preferences",
"description": "Updates an existing example. Requires authentication.", "description": "Creates or updates preferences for a user with merge semantics. Requires authentication.",
"tags": []string{"Examples"}, "tags": []string{"Preferences"},
"security": []map[string][]string{{"bearer": {}}}, "security": []map[string][]string{{"bearer": {}}},
"parameters": []any{openapi.IDParam()}, "parameters": []any{userIDParam},
"requestBody": openapi.RequestBody(openapi.Ref("UpdateExampleRequest"), true), "requestBody": openapi.RequestBody(openapi.Ref("UpdatePreferencesRequest"), true),
"responses": map[string]any{ "responses": map[string]any{
"200": openapi.OpResponse("Updated", openapi.ResponseSchema(openapi.Ref("Example"))), "200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("Preferences"))),
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()), "400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()), "401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()), "403": openapi.OpResponse("Forbidden", openapi.ErrorResponseSchema()),
},
})
// Delete example
spec.AddPath("/api/preferences-api/examples/{id}", "delete", map[string]any{
"summary": "Delete example",
"description": "Deletes an example by ID. Requires authentication.",
"tags": []string{"Examples"},
"security": []map[string][]string{{"bearer": {}}},
"parameters": []any{openapi.IDParam()},
"responses": map[string]any{
"204": openapi.OpResponseNoContent(),
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
}, },
}) })

View File

@ -1,21 +1,11 @@
// Package domain contains pure domain models with no external dependencies.
// These types represent the core business concepts of the service.
package domain package domain
import "errors" import "errors"
// Domain errors - these are business-level errors that should be translated
// to appropriate HTTP status codes by the handler layer.
var ( var (
// ErrNotFound indicates a requested resource does not exist. ErrPreferencesNotFound = errors.New("preferences not found")
ErrNotFound = errors.New("not found") ErrInvalidTheme = errors.New("invalid theme value")
ErrInvalidLanguage = errors.New("invalid language value")
// ErrExampleNotFound indicates the requested example does not exist. ErrInvalidDigest = errors.New("invalid digest value")
ErrExampleNotFound = errors.New("example not found") ErrInvalidPreferences = errors.New("invalid preferences")
// 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")
) )

View File

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

View File

@ -0,0 +1,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
}
}
}

View File

@ -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")
}
}

View File

@ -1,37 +0,0 @@
// Package port defines interfaces (ports) for external dependencies.
// These interfaces define the contracts between the application core and
// infrastructure adapters, enabling testability and flexibility.
package port
import (
"context"
"git.threesix.ai/jordan/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)
}

View File

@ -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
}

View File

@ -1,137 +0,0 @@
// Package service provides business logic / use cases for the application.
// Services orchestrate domain operations using port interfaces.
package service
import (
"context"
"errors"
"github.com/google/uuid"
"git.threesix.ai/jordan/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
}

View File

@ -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 &copy, nil
}
func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error {
m.mu.Lock()
defer m.mu.Unlock()
// Store a copy
copy := *example
m.examples[example.ID] = &copy
return nil
}
func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.examples[example.ID]; !ok {
return domain.ErrExampleNotFound
}
// Store a copy
copy := *example
m.examples[example.ID] = &copy
return nil
}
func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.examples[id]; !ok {
return domain.ErrExampleNotFound
}
delete(m.examples, id)
return nil
}
func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, e := range m.examples {
if e.Name == name {
return true, nil
}
}
return false, nil
}
func TestExampleService_Create(t *testing.T) {
repo := newMockExampleRepository()
svc := NewExampleService(repo, logging.Nop())
t.Run("creates example successfully", func(t *testing.T) {
example, err := svc.Create(context.Background(), CreateInput{
Name: "Test Example",
Description: "A test description",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if example.Name != "Test Example" {
t.Errorf("expected name 'Test Example', got '%s'", example.Name)
}
if example.ID.IsZero() {
t.Error("expected non-empty ID")
}
})
t.Run("rejects duplicate name", func(t *testing.T) {
_, err := svc.Create(context.Background(), CreateInput{
Name: "Test Example",
Description: "Another description",
})
if err != domain.ErrDuplicateExample {
t.Errorf("expected ErrDuplicateExample, got %v", err)
}
})
t.Run("rejects empty name", func(t *testing.T) {
_, err := svc.Create(context.Background(), CreateInput{
Name: "",
Description: "Description",
})
if err != domain.ErrInvalidExampleName {
t.Errorf("expected ErrInvalidExampleName, got %v", err)
}
})
}
func TestExampleService_Get(t *testing.T) {
repo := newMockExampleRepository()
svc := NewExampleService(repo, logging.Nop())
// Create an example first
created, _ := svc.Create(context.Background(), CreateInput{
Name: "Get Test",
Description: "Description",
})
t.Run("returns existing example", func(t *testing.T) {
example, err := svc.Get(context.Background(), created.ID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if example.Name != "Get Test" {
t.Errorf("expected name 'Get Test', got '%s'", example.Name)
}
})
t.Run("returns not found for missing example", func(t *testing.T) {
_, err := svc.Get(context.Background(), "nonexistent-id")
if err != domain.ErrExampleNotFound {
t.Errorf("expected ErrExampleNotFound, got %v", err)
}
})
}
func TestExampleService_Update(t *testing.T) {
repo := newMockExampleRepository()
svc := NewExampleService(repo, logging.Nop())
// Create examples
example1, _ := svc.Create(context.Background(), CreateInput{
Name: "Update Test 1",
Description: "Original",
})
_, _ = svc.Create(context.Background(), CreateInput{
Name: "Update Test 2",
Description: "Other",
})
t.Run("updates example successfully", func(t *testing.T) {
updated, err := svc.Update(context.Background(), example1.ID, UpdateInput{
Name: "Updated Name",
Description: "Updated description",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if updated.Name != "Updated Name" {
t.Errorf("expected name 'Updated Name', got '%s'", updated.Name)
}
})
t.Run("allows same name on same example", func(t *testing.T) {
_, err := svc.Update(context.Background(), example1.ID, UpdateInput{
Name: "Updated Name",
Description: "Same name",
})
if err != nil {
t.Errorf("unexpected error updating with same name: %v", err)
}
})
t.Run("rejects name conflict", func(t *testing.T) {
_, err := svc.Update(context.Background(), example1.ID, UpdateInput{
Name: "Update Test 2",
Description: "Conflict",
})
if err != domain.ErrDuplicateExample {
t.Errorf("expected ErrDuplicateExample, got %v", err)
}
})
t.Run("returns not found for missing example", func(t *testing.T) {
_, err := svc.Update(context.Background(), "nonexistent-id", UpdateInput{
Name: "Anything",
Description: "",
})
if err != domain.ErrExampleNotFound {
t.Errorf("expected ErrExampleNotFound, got %v", err)
}
})
}
func TestExampleService_Delete(t *testing.T) {
repo := newMockExampleRepository()
svc := NewExampleService(repo, logging.Nop())
// Create an example first
created, _ := svc.Create(context.Background(), CreateInput{
Name: "Delete Test",
Description: "To be deleted",
})
t.Run("deletes example successfully", func(t *testing.T) {
err := svc.Delete(context.Background(), created.ID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify deleted
_, err = svc.Get(context.Background(), created.ID)
if err != domain.ErrExampleNotFound {
t.Errorf("expected ErrExampleNotFound after delete, got %v", err)
}
})
t.Run("returns not found for missing example", func(t *testing.T) {
err := svc.Delete(context.Background(), "nonexistent-id")
if err != domain.ErrExampleNotFound {
t.Errorf("expected ErrExampleNotFound, got %v", err)
}
})
}
func TestExampleService_List(t *testing.T) {
repo := newMockExampleRepository()
svc := NewExampleService(repo, logging.Nop())
t.Run("returns empty list initially", func(t *testing.T) {
examples, err := svc.List(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(examples) != 0 {
t.Errorf("expected 0 examples, got %d", len(examples))
}
})
// Create some examples
_, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 1", Description: ""})
_, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 2", Description: ""})
t.Run("returns all examples", func(t *testing.T) {
examples, err := svc.List(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(examples) != 2 {
t.Errorf("expected 2 examples, got %d", len(examples))
}
})
}

View File

@ -0,0 +1,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
}

View File

@ -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)
}
})
}