build: /implement-feature user-preferences
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
3331b4e68f
commit
1afe983cd6
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
210
services/preferences-api/internal/api/handlers/preferences.go
Normal file
210
services/preferences-api/internal/api/handlers/preferences.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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))
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
112
services/preferences-api/internal/domain/preferences.go
Normal file
112
services/preferences-api/internal/domain/preferences.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
211
services/preferences-api/internal/domain/preferences_test.go
Normal file
211
services/preferences-api/internal/domain/preferences_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
17
services/preferences-api/internal/port/preferences.go
Normal file
17
services/preferences-api/internal/port/preferences.go
Normal 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
|
||||||
|
}
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
59
services/preferences-api/internal/service/preferences.go
Normal file
59
services/preferences-api/internal/service/preferences.go
Normal 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
|
||||||
|
}
|
||||||
153
services/preferences-api/internal/service/preferences_test.go
Normal file
153
services/preferences-api/internal/service/preferences_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user