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

This commit is contained in:
rdev-worker 2026-02-08 09:29:22 +00:00
parent f18c076325
commit e3e19a3fa8
24 changed files with 1132 additions and 1342 deletions

View File

@ -1,20 +1,36 @@
slug: user-preferences slug: user-preferences
title: User Preferences API title: User Preferences API
created: 2026-02-08T09:06:06.819418064Z created: 2026-02-08T09:06:06.819418064Z
phase: draft phase: implementation
phase_history: phase_history:
- phase: draft - phase: draft
entered: 2026-02-08T09:06:06.819418064Z entered: 2026-02-08T09:06:06.819418064Z
exited: 2026-02-08T09:20:31.709383944Z
- phase: specified
entered: 2026-02-08T09:20:31.709383944Z
exited: 2026-02-08T09:20:31.715778577Z
- phase: planned
entered: 2026-02-08T09:20:31.715778577Z
exited: 2026-02-08T09:20:31.721032282Z
- phase: ready
entered: 2026-02-08T09:20:31.721032282Z
exited: 2026-02-08T09:20:31.727173118Z
- phase: implementation
entered: 2026-02-08T09:20:31.727173118Z
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-08T09:20:28.25927382Z
qa_plan: qa_plan:
status: draft status: approved
path: qa-plan.md path: qa-plan.md
approved_by: user
approved_at: 2026-02-08T09:20:28.26939164Z
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-08T09:20:28.254388769Z
tasks: tasks:
status: draft status: approved
path: tasks.md path: tasks.md
approved_by: user
approved_at: 2026-02-08T09:20:28.264355002Z
total: 8 total: 8
completed: 8
tasks: tasks:
- id: task-001 - id: task-001
title: Domain layer - preferences types, validation, defaults, and errors title: Domain layer - preferences types, validation, defaults, and errors
status: pending status: complete
started_at: 2026-02-08T09:20:36.310646192Z
done_at: 2026-02-08T09:21:30.037686457Z
- id: task-002 - id: task-002
title: Port layer - PreferencesRepository interface title: Port layer - PreferencesRepository interface
status: pending status: complete
started_at: 2026-02-08T09:21:33.903171827Z
done_at: 2026-02-08T09:22:14.064726881Z
- id: task-003 - id: task-003
title: Database migration - user_preferences table title: Database migration - user_preferences table
status: pending status: complete
started_at: 2026-02-08T09:21:33.907199366Z
done_at: 2026-02-08T09:22:14.069927938Z
- id: task-004 - id: task-004
title: PostgreSQL adapter - PreferencesRepository implementation title: PostgreSQL adapter - PreferencesRepository implementation
status: pending status: complete
started_at: 2026-02-08T09:22:18.985170366Z
done_at: 2026-02-08T09:23:28.657646091Z
- id: task-005 - id: task-005
title: Service layer - PreferencesService with get/set logic and tests title: Service layer - PreferencesService with get/set logic and tests
status: pending status: complete
started_at: 2026-02-08T09:22:18.990366915Z
done_at: 2026-02-08T09:23:28.663751019Z
- id: task-006 - id: task-006
title: HTTP handlers - GET and PUT with auth, mapping, and tests title: HTTP handlers - GET and PUT with auth, mapping, and tests
status: pending status: complete
started_at: 2026-02-08T09:23:28.66873089Z
done_at: 2026-02-08T09:25:11.5724865Z
- id: task-007 - id: task-007
title: Routes, OpenAPI spec, and main.go wiring title: Routes, OpenAPI spec, and main.go wiring
status: pending status: complete
started_at: 2026-02-08T09:25:11.579240669Z
done_at: 2026-02-08T09:26:45.825892515Z
- id: task-008 - id: task-008
title: Remove example scaffold code title: Remove example scaffold code
status: pending status: complete
started_at: 2026-02-08T09:26:45.832233337Z
done_at: 2026-02-08T09:28:59.532153587Z

View File

@ -4,10 +4,10 @@ project:
active_work: active_work:
features: features:
- slug: user-preferences - slug: user-preferences
phase: draft phase: implementation
blocked: [] blocked: []
last_updated: 2026-02-08T09:06:06.8197296Z last_updated: 2026-02-08T09:28:59.532990472Z
last_action: CREATE_FEATURE last_action: COMPLETE_TASK
last_actor: cli last_actor: cli
history: history:
- timestamp: 2026-02-08T09:06:06.819729169Z - timestamp: 2026-02-08T09:06:06.819729169Z
@ -15,3 +15,83 @@ history:
feature: user-preferences feature: user-preferences
actor: cli actor: cli
result: success result: success
- timestamp: 2026-02-08T09:20:28.254858111Z
action: APPROVE_ARTIFACT
feature: user-preferences
actor: user
result: success
- timestamp: 2026-02-08T09:20:28.259691267Z
action: APPROVE_ARTIFACT
feature: user-preferences
actor: user
result: success
- timestamp: 2026-02-08T09:20:28.264889368Z
action: APPROVE_ARTIFACT
feature: user-preferences
actor: user
result: success
- timestamp: 2026-02-08T09:20:28.270015674Z
action: APPROVE_ARTIFACT
feature: user-preferences
actor: user
result: success
- timestamp: 2026-02-08T09:20:31.709946893Z
action: TRANSITION
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T09:20:31.716562232Z
action: TRANSITION
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T09:20:31.722532105Z
action: TRANSITION
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T09:20:31.727808804Z
action: TRANSITION
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T09:21:30.038352049Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T09:22:14.06532107Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T09:22:14.070655487Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T09:23:28.658494578Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T09:23:28.664874463Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T09:25:11.573327772Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T09:26:45.827793942Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T09:28:59.532989701Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success

View File

@ -2,15 +2,19 @@
package main package main
import ( import (
"context"
"flag" "flag"
"fmt" "fmt"
"os" "os"
"git.threesix.ai/jordan/slack5-1770541397/pkg/app" "git.threesix.ai/jordan/slack5-1770541397/pkg/app"
"git.threesix.ai/jordan/slack5-1770541397/pkg/config"
"git.threesix.ai/jordan/slack5-1770541397/pkg/database"
"git.threesix.ai/jordan/slack5-1770541397/pkg/logging" "git.threesix.ai/jordan/slack5-1770541397/pkg/logging"
"git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/adapter/memory" "git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/adapter/postgres"
"git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/api" "git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/api"
"git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/service" "git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/service"
"git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/migrations"
) )
func main() { func main() {
@ -33,17 +37,37 @@ func main() {
// Create logger // Create logger
logger := logging.Default() logger := logging.Default()
// Connect to database
dbCfg := config.ReadDatabaseConfig()
pool, err := database.Connect(context.Background(), dbCfg.URL, database.Options{
MaxOpenConns: dbCfg.MaxOpenConns,
MaxIdleConns: dbCfg.MaxIdleConns,
ConnMaxLifetime: dbCfg.ConnMaxLifetime,
})
if err != nil {
logger.Error("failed to connect to database", "error", err)
os.Exit(1)
}
// Run migrations
database.MustRunMigrations(context.Background(), pool, migrations.FS, ".")
// Create adapters (repositories) // Create adapters (repositories)
exampleRepo := memory.NewExampleRepository() prefsRepo := postgres.NewPreferencesRepository(pool.DB, logger)
// Create services (business logic) // Create services (business logic)
exampleService := service.NewExampleService(exampleRepo, logger) preferencesService := service.NewPreferencesService(prefsRepo, logger)
// Create application // Create application
application := app.New("preferences-api", app.WithDefaultPort(8001)) application := app.New("preferences-api", app.WithDefaultPort(8001))
// Register cleanup on shutdown
application.OnShutdown(func(ctx context.Context) error {
return pool.Close()
})
// Register routes with dependency injection // Register routes with dependency injection
api.RegisterRoutes(application, exampleService) api.RegisterRoutes(application, preferencesService)
// Start server // Start server
application.Run() application.Run()

View File

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

View File

@ -0,0 +1,84 @@
// Package postgres provides PostgreSQL implementations of port interfaces.
package postgres
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"github.com/jmoiron/sqlx"
"git.threesix.ai/jordan/slack5-1770541397/pkg/logging"
"git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/port"
)
// preferencesRow represents a row in the user_preferences table.
type preferencesRow struct {
UserID string `db:"user_id"`
Preferences []byte `db:"preferences"`
UpdatedAt time.Time `db:"updated_at"`
}
// PreferencesRepository implements port.PreferencesRepository using PostgreSQL.
type PreferencesRepository struct {
db *sqlx.DB
logger *logging.Logger
}
var _ port.PreferencesRepository = (*PreferencesRepository)(nil)
// NewPreferencesRepository creates a new PostgreSQL preferences repository.
func NewPreferencesRepository(db *sqlx.DB, logger *logging.Logger) *PreferencesRepository {
return &PreferencesRepository{
db: db,
logger: logger.WithComponent("PreferencesRepository"),
}
}
// Get retrieves preferences for a user by ID.
// Returns domain.ErrPreferencesNotFound if no row exists.
func (r *PreferencesRepository) Get(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error) {
var row preferencesRow
err := r.db.GetContext(ctx, &row,
`SELECT user_id, preferences, updated_at FROM user_preferences WHERE user_id = $1`,
string(userID),
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrPreferencesNotFound
}
return nil, err
}
var prefs domain.Preferences
if err := json.Unmarshal(row.Preferences, &prefs); err != nil {
return nil, err
}
return &domain.UserPreferences{
UserID: domain.UserID(row.UserID),
Preferences: prefs,
UpdatedAt: row.UpdatedAt,
}, nil
}
// Upsert creates or replaces preferences for a user.
func (r *PreferencesRepository) Upsert(ctx context.Context, prefs *domain.UserPreferences) error {
prefsJSON, err := json.Marshal(prefs.Preferences)
if err != nil {
return err
}
_, err = r.db.ExecContext(ctx, `
INSERT INTO user_preferences (user_id, preferences, updated_at)
VALUES ($1, $2, $3)
ON CONFLICT (user_id) DO UPDATE SET
preferences = $2,
updated_at = $3
`, string(prefs.UserID), prefsJSON, prefs.UpdatedAt)
return err
}

View File

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

View File

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

View File

@ -0,0 +1,144 @@
package handlers
import (
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"git.threesix.ai/jordan/slack5-1770541397/pkg/app"
"git.threesix.ai/jordan/slack5-1770541397/pkg/auth"
"git.threesix.ai/jordan/slack5-1770541397/pkg/httperror"
"git.threesix.ai/jordan/slack5-1770541397/pkg/httpresponse"
"git.threesix.ai/jordan/slack5-1770541397/pkg/logging"
"git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/service"
)
// Preferences handles HTTP requests for user preferences.
type Preferences struct {
svc *service.PreferencesService
logger *logging.Logger
}
// NewPreferences creates a new Preferences handler with injected dependencies.
func NewPreferences(svc *service.PreferencesService, logger *logging.Logger) *Preferences {
return &Preferences{
svc: svc,
logger: logger.WithComponent("PreferencesHandler"),
}
}
// PutPreferencesRequest is the request body for setting preferences.
type PutPreferencesRequest struct {
Preferences PreferencesPayload `json:"preferences" validate:"required"`
}
// PreferencesPayload represents the preferences JSON structure in requests/responses.
type PreferencesPayload struct {
Theme string `json:"theme,omitempty"`
Language string `json:"language,omitempty"`
Notifications *NotificationPreferencesPayload `json:"notifications,omitempty"`
}
// NotificationPreferencesPayload represents notification settings in requests/responses.
type NotificationPreferencesPayload struct {
Email bool `json:"email"`
Push bool `json:"push"`
SMS bool `json:"sms"`
}
// PreferencesResponse is the response for a user's preferences.
type PreferencesResponse struct {
UserID string `json:"user_id"`
Preferences PreferencesPayload `json:"preferences"`
UpdatedAt string `json:"updated_at"`
}
// toPreferencesResponse converts a domain UserPreferences to an API response.
func toPreferencesResponse(up *domain.UserPreferences) PreferencesResponse {
return PreferencesResponse{
UserID: up.UserID.String(),
Preferences: PreferencesPayload{
Theme: up.Preferences.Theme,
Language: up.Preferences.Language,
Notifications: &NotificationPreferencesPayload{
Email: up.Preferences.Notifications.Email,
Push: up.Preferences.Notifications.Push,
SMS: up.Preferences.Notifications.SMS,
},
},
UpdatedAt: up.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// toDomainPreferences converts an API payload to a domain Preferences.
func toDomainPreferences(p PreferencesPayload) domain.Preferences {
prefs := domain.Preferences{
Theme: p.Theme,
Language: p.Language,
}
if p.Notifications != nil {
prefs.Notifications = domain.NotificationPreferences{
Email: p.Notifications.Email,
Push: p.Notifications.Push,
SMS: p.Notifications.SMS,
}
}
return prefs
}
// Get returns the preferences for a user.
func (h *Preferences) Get(w http.ResponseWriter, r *http.Request) error {
userID := chi.URLParam(r, "user_id")
// Authorization: authenticated user must match path user_id
authUser := auth.GetUser(r.Context())
if authUser == nil || authUser.ID != userID {
return httperror.Forbidden("access denied: can only access own preferences")
}
prefs, err := h.svc.GetPreferences(r.Context(), domain.UserID(userID))
if err != nil {
return mapPreferencesDomainError(err)
}
httpresponse.OK(w, r, toPreferencesResponse(prefs))
return nil
}
// Put creates or replaces preferences for a user.
func (h *Preferences) Put(w http.ResponseWriter, r *http.Request) error {
userID := chi.URLParam(r, "user_id")
// Authorization: authenticated user must match path user_id
authUser := auth.GetUser(r.Context())
if authUser == nil || authUser.ID != userID {
return httperror.Forbidden("access denied: can only modify own preferences")
}
var req PutPreferencesRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
prefs, err := h.svc.SetPreferences(r.Context(), domain.UserID(userID), toDomainPreferences(req.Preferences))
if err != nil {
return mapPreferencesDomainError(err)
}
httpresponse.OK(w, r, toPreferencesResponse(prefs))
return nil
}
// mapPreferencesDomainError converts domain errors to HTTP errors.
func mapPreferencesDomainError(err error) error {
switch {
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 at most 10 characters")
default:
return err
}
}

View File

@ -0,0 +1,263 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/go-chi/chi/v5"
"git.threesix.ai/jordan/slack5-1770541397/pkg/auth"
"git.threesix.ai/jordan/slack5-1770541397/pkg/httperror"
"git.threesix.ai/jordan/slack5-1770541397/pkg/logging"
"git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/port"
"git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/service"
)
// mockPreferencesRepository implements port.PreferencesRepository for handler tests.
type mockPreferencesRepository struct {
mu sync.RWMutex
store map[domain.UserID]*domain.UserPreferences
}
var _ port.PreferencesRepository = (*mockPreferencesRepository)(nil)
func newMockPreferencesRepository() *mockPreferencesRepository {
return &mockPreferencesRepository{
store: make(map[domain.UserID]*domain.UserPreferences),
}
}
func (m *mockPreferencesRepository) Get(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error) {
m.mu.RLock()
defer m.mu.RUnlock()
prefs, ok := m.store[userID]
if !ok {
return nil, domain.ErrPreferencesNotFound
}
copy := *prefs
return &copy, nil
}
func (m *mockPreferencesRepository) Upsert(ctx context.Context, prefs *domain.UserPreferences) error {
m.mu.Lock()
defer m.mu.Unlock()
copy := *prefs
m.store[prefs.UserID] = &copy
return nil
}
func newTestPreferencesHandler() (*Preferences, *mockPreferencesRepository) {
repo := newMockPreferencesRepository()
svc := service.NewPreferencesService(repo, logging.Nop())
handler := NewPreferences(svc, logging.Nop())
return handler, repo
}
// withAuthUser creates an HTTP handler that injects an auth user into the context.
func withAuthUser(userID string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := auth.SetUser(r.Context(), &auth.User{ID: userID})
next.ServeHTTP(w, r.WithContext(ctx))
}
}
func TestPreferences_Get(t *testing.T) {
handler, repo := newTestPreferencesHandler()
// Seed stored preferences for user-456
repo.store["user-456"] = &domain.UserPreferences{
UserID: "user-456",
Preferences: domain.Preferences{
Theme: "dark",
Language: "fr",
Notifications: domain.NotificationPreferences{
Email: false,
Push: true,
SMS: true,
},
},
}
tests := []struct {
name string
pathUserID string
authUserID string
wantStatus int
wantTheme string
}{
{
name: "returns defaults for new user",
pathUserID: "user-123",
authUserID: "user-123",
wantStatus: http.StatusOK,
wantTheme: "system",
},
{
name: "returns stored preferences",
pathUserID: "user-456",
authUserID: "user-456",
wantStatus: http.StatusOK,
wantTheme: "dark",
},
{
name: "returns forbidden for different user",
pathUserID: "user-456",
authUserID: "user-other",
wantStatus: http.StatusForbidden,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := chi.NewRouter()
r.Get("/api/preferences-api/preferences/{user_id}", withAuthUser(tt.authUserID, func(w http.ResponseWriter, r *http.Request) {
if err := handler.Get(w, r); err != nil {
writeErrorStatus(w, err)
return
}
}))
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/"+tt.pathUserID, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
}
if tt.wantStatus == http.StatusOK && tt.wantTheme != "" {
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"] != tt.wantTheme {
t.Errorf("expected theme '%s', got '%v'", tt.wantTheme, prefs["theme"])
}
}
})
}
}
func TestPreferences_Put(t *testing.T) {
handler, _ := newTestPreferencesHandler()
tests := []struct {
name string
pathUserID string
authUserID string
body any
wantStatus int
}{
{
name: "sets preferences successfully",
pathUserID: "user-123",
authUserID: "user-123",
body: PutPreferencesRequest{
Preferences: PreferencesPayload{
Theme: "dark",
Language: "en",
Notifications: &NotificationPreferencesPayload{
Email: true,
Push: false,
SMS: false,
},
},
},
wantStatus: http.StatusOK,
},
{
name: "returns forbidden for different user",
pathUserID: "user-123",
authUserID: "user-other",
body: PutPreferencesRequest{
Preferences: PreferencesPayload{
Theme: "dark",
Language: "en",
},
},
wantStatus: http.StatusForbidden,
},
{
name: "rejects invalid theme",
pathUserID: "user-123",
authUserID: "user-123",
body: PutPreferencesRequest{
Preferences: PreferencesPayload{
Theme: "neon",
Language: "en",
},
},
wantStatus: http.StatusBadRequest,
},
{
name: "rejects language too long",
pathUserID: "user-123",
authUserID: "user-123",
body: PutPreferencesRequest{
Preferences: PreferencesPayload{
Theme: "dark",
Language: "abcdefghijk",
},
},
wantStatus: http.StatusBadRequest,
},
{
name: "rejects empty body",
pathUserID: "user-123",
authUserID: "user-123",
body: nil,
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := chi.NewRouter()
r.Put("/api/preferences-api/preferences/{user_id}", withAuthUser(tt.authUserID, func(w http.ResponseWriter, r *http.Request) {
if err := handler.Put(w, r); err != nil {
writeErrorStatus(w, err)
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.MethodPut, "/api/preferences-api/preferences/"+tt.pathUserID, 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 (body: %s)", tt.wantStatus, w.Code, w.Body.String())
}
})
}
}
// writeErrorStatus writes an HTTP error status based on the error type.
func writeErrorStatus(w http.ResponseWriter, err error) {
w.WriteHeader(httperror.StatusCode(err))
}

View File

@ -11,32 +11,24 @@ 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 // Initialize handlers with injected services
healthHandler := handlers.NewHealth(logger) healthHandler := handlers.NewHealth(logger)
exampleHandler := handlers.NewExample(exampleService, logger) prefsHandler := handlers.NewPreferences(preferencesService, logger)
// Build and mount OpenAPI spec // Build and mount OpenAPI spec
spec := NewServiceSpec() spec := NewServiceSpec()
application.EnableDocs(spec) application.EnableDocs(spec)
// Register API routes under /api/{service-name} to match ingress path routing. // Register API routes under /api/{service-name} to match ingress path routing.
// The ingress routes /api/preferences-api/* to this service.
application.Route("/api/preferences-api", func(r app.Router) { application.Route("/api/preferences-api", func(r app.Router) {
r.Get("/health", healthHandler.Check) r.Get("/health", healthHandler.Check)
// Public routes (no auth required) // Protected routes — auth required for all preference endpoints
r.Get("/examples", app.Wrap(exampleHandler.List)) r.Route("/preferences", func(r app.Router) {
r.Get("/examples/{id}", app.Wrap(exampleHandler.Get))
// Protected routes (auth required when enabled)
r.Group(func(r app.Router) {
if cfg.AuthEnabled { if cfg.AuthEnabled {
r.Use(auth.Middleware(auth.MiddlewareConfig{ r.Use(auth.Middleware(auth.MiddlewareConfig{
Validator: auth.NewJWTValidator(auth.JWTConfig{ Validator: auth.NewJWTValidator(auth.JWTConfig{
@ -46,9 +38,8 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
})) }))
} }
r.Post("/examples", app.Wrap(exampleHandler.Create)) r.Get("/{user_id}", app.Wrap(prefsHandler.Get))
r.Put("/examples/{id}", app.Wrap(exampleHandler.Update)) r.Put("/{user_id}", app.Wrap(prefsHandler.Put))
r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete))
}) })
}) })
} }

View File

@ -5,30 +5,37 @@ import "git.threesix.ai/jordan/slack5-1770541397/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 // Define reusable schemas
spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{ spec.WithSchema("NotificationPreferences", 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"), "sms": openapi.Bool().WithDescription("SMS notifications enabled"),
"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{
"theme": openapi.StringEnum("light", "dark", "system").WithDescription("UI theme preference"),
"language": openapi.StringWithMinMax(0, 10).WithDescription("BCP-47 language tag").WithExample("en"),
"notifications": openapi.Ref("NotificationPreferences").WithDescription("Notification settings"),
}))
spec.WithSchema("UserPreferencesResponse", openapi.Object(map[string]openapi.Schema{
"user_id": openapi.String().WithDescription("User identifier"),
"preferences": openapi.Ref("Preferences").WithDescription("User preferences"),
"updated_at": openapi.DateTime().WithDescription("Last update timestamp"),
}, "user_id", "preferences", "updated_at"))
spec.WithSchema("PutPreferencesRequest", openapi.Object(map[string]openapi.Schema{
"preferences": openapi.Ref("Preferences").WithDescription("Preferences to save"),
}, "preferences"))
// user_id path parameter
userIDParam := openapi.PathParam("user_id", "User identifier")
// 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 +48,33 @@ 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 the user's preferences. Returns defaults if none have been saved. 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("UserPreferencesResponse"))),
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
"403": openapi.OpResponse("Forbidden", openapi.ErrorResponseSchema()),
},
})
// Put preferences
spec.AddPath("/api/preferences-api/preferences/{user_id}", "put", map[string]any{
"summary": "Set user preferences",
"description": "Creates or replaces the user's preferences. Requires authentication.",
"tags": []string{"Preferences"},
"security": []map[string][]string{{"bearer": {}}},
"parameters": []any{userIDParam},
"requestBody": openapi.RequestBody(openapi.Ref("PutPreferencesRequest"), true),
"responses": map[string]any{
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("UserPreferencesResponse"))),
"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()),
},
})
// Update example
spec.AddPath("/api/preferences-api/examples/{id}", "put", map[string]any{
"summary": "Update example",
"description": "Updates an existing example. Requires authentication.",
"tags": []string{"Examples"},
"security": []map[string][]string{{"bearer": {}}},
"parameters": []any{openapi.IDParam()},
"requestBody": openapi.RequestBody(openapi.Ref("UpdateExampleRequest"), true),
"responses": map[string]any{
"200": openapi.OpResponse("Updated", openapi.ResponseSchema(openapi.Ref("Example"))),
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
},
})
// Delete example
spec.AddPath("/api/preferences-api/examples/{id}", "delete", map[string]any{
"summary": "Delete example",
"description": "Deletes an example by ID. Requires authentication.",
"tags": []string{"Examples"},
"security": []map[string][]string{{"bearer": {}}},
"parameters": []any{openapi.IDParam()},
"responses": map[string]any{
"204": openapi.OpResponseNoContent(),
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
}, },
}) })

View File

@ -10,12 +10,15 @@ var (
// ErrNotFound indicates a requested resource does not exist. // ErrNotFound indicates a requested resource does not exist.
ErrNotFound = errors.New("not found") ErrNotFound = errors.New("not found")
// ErrExampleNotFound indicates the requested example does not exist. // ErrPreferencesNotFound indicates no preferences exist for the user.
ErrExampleNotFound = errors.New("example not found") ErrPreferencesNotFound = errors.New("preferences not found")
// ErrDuplicateExample indicates an example with the same name already exists. // ErrInvalidTheme indicates the theme value is not one of light, dark, system.
ErrDuplicateExample = errors.New("example with this name already exists") ErrInvalidTheme = errors.New("invalid theme: must be one of light, dark, system")
// ErrInvalidExampleName indicates the example name is invalid. // ErrInvalidLanguage indicates the language value exceeds 10 characters.
ErrInvalidExampleName = errors.New("invalid example name") ErrInvalidLanguage = errors.New("invalid language: must be at most 10 characters")
// ErrForbidden indicates the user is not authorized for this operation.
ErrForbidden = errors.New("forbidden")
) )

View File

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

View File

@ -0,0 +1,143 @@
package domain
import (
"encoding/json"
"time"
"unicode/utf8"
)
// UserID is a strongly-typed identifier for users.
type UserID string
// String returns the string representation of the ID.
func (id UserID) String() string {
return string(id)
}
// ValidThemes lists the allowed theme values.
var ValidThemes = []string{"light", "dark", "system"}
// NotificationPreferences controls notification delivery channels.
type NotificationPreferences struct {
Email bool `json:"email"`
Push bool `json:"push"`
SMS bool `json:"sms"`
}
// Preferences holds a user's preference settings.
// Known fields are typed; unknown keys are preserved in Extra for extensibility.
type Preferences struct {
Theme string `json:"theme"`
Language string `json:"language"`
Notifications NotificationPreferences `json:"notifications"`
Extra map[string]any `json:"-"`
}
// MarshalJSON merges known fields and Extra into a single JSON object.
func (p Preferences) MarshalJSON() ([]byte, error) {
// Build a map with known fields
m := make(map[string]any)
// Copy extra fields first (so known fields override if there's overlap)
for k, v := range p.Extra {
m[k] = v
}
m["theme"] = p.Theme
m["language"] = p.Language
m["notifications"] = p.Notifications
return json.Marshal(m)
}
// UnmarshalJSON decodes known fields into struct fields and captures unknown keys in Extra.
func (p *Preferences) UnmarshalJSON(data []byte) error {
// Decode known fields via an alias to avoid recursion
type Alias Preferences
var alias Alias
if err := json.Unmarshal(data, &alias); err != nil {
return err
}
// Decode all fields into a map to find unknown keys
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
// Copy known fields
p.Theme = alias.Theme
p.Language = alias.Language
p.Notifications = alias.Notifications
// Capture unknown keys
knownKeys := map[string]bool{
"theme": true,
"language": true,
"notifications": true,
}
p.Extra = nil
for k, v := range raw {
if knownKeys[k] {
continue
}
if p.Extra == nil {
p.Extra = make(map[string]any)
}
var val any
if err := json.Unmarshal(v, &val); err != nil {
return err
}
p.Extra[k] = val
}
return nil
}
// MaxLanguageLen is the maximum rune length for a language tag.
const MaxLanguageLen = 10
// Validate checks the known preference fields.
// Returns ErrInvalidTheme if theme is not a valid value.
// Returns ErrInvalidLanguage if language exceeds MaxLanguageLen runes.
func (p *Preferences) Validate() error {
if p.Theme != "" {
valid := false
for _, t := range ValidThemes {
if p.Theme == t {
valid = true
break
}
}
if !valid {
return ErrInvalidTheme
}
}
if utf8.RuneCountInString(p.Language) > MaxLanguageLen {
return ErrInvalidLanguage
}
return nil
}
// DefaultPreferences returns the default preference values.
func DefaultPreferences() Preferences {
return Preferences{
Theme: "system",
Language: "en",
Notifications: NotificationPreferences{
Email: true,
Push: true,
SMS: false,
},
}
}
// UserPreferences is the aggregate associating preferences with a user.
type UserPreferences struct {
UserID UserID
Preferences Preferences
UpdatedAt time.Time
}

View File

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

View File

@ -0,0 +1,18 @@
package port
import (
"context"
"git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/domain"
)
// PreferencesRepository defines the interface for user preferences persistence.
// Implementations may use databases or in-memory storage.
type PreferencesRepository interface {
// Get returns the preferences for a user.
// Returns domain.ErrPreferencesNotFound if no preferences exist.
Get(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error)
// Upsert creates or replaces the preferences for a user.
Upsert(ctx context.Context, prefs *domain.UserPreferences) error
}

View File

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

View File

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

View File

@ -0,0 +1,62 @@
package service
import (
"context"
"errors"
"time"
"git.threesix.ai/jordan/slack5-1770541397/pkg/logging"
"git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/port"
)
// PreferencesService handles preference-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"),
}
}
// GetPreferences returns the preferences for a user.
// If no preferences are stored, default preferences are returned.
func (s *PreferencesService) GetPreferences(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error) {
prefs, err := s.repo.Get(ctx, userID)
if err != nil {
if errors.Is(err, domain.ErrPreferencesNotFound) {
defaults := domain.DefaultPreferences()
return &domain.UserPreferences{
UserID: userID,
Preferences: defaults,
}, nil
}
return nil, err
}
return prefs, nil
}
// SetPreferences validates and stores preferences for a user.
func (s *PreferencesService) SetPreferences(ctx context.Context, userID domain.UserID, prefs domain.Preferences) (*domain.UserPreferences, error) {
if err := prefs.Validate(); err != nil {
return nil, err
}
userPrefs := &domain.UserPreferences{
UserID: userID,
Preferences: prefs,
UpdatedAt: time.Now().UTC(),
}
if err := s.repo.Upsert(ctx, userPrefs); err != nil {
return nil, err
}
s.logger.Info("preferences updated", "user_id", string(userID))
return userPrefs, nil
}

View File

@ -0,0 +1,181 @@
package service
import (
"context"
"sync"
"testing"
"git.threesix.ai/jordan/slack5-1770541397/pkg/logging"
"git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/port"
)
// mockPreferencesRepository implements port.PreferencesRepository for testing.
type mockPreferencesRepository struct {
mu sync.RWMutex
store map[domain.UserID]*domain.UserPreferences
}
var _ port.PreferencesRepository = (*mockPreferencesRepository)(nil)
func newMockPreferencesRepository() *mockPreferencesRepository {
return &mockPreferencesRepository{
store: make(map[domain.UserID]*domain.UserPreferences),
}
}
func (m *mockPreferencesRepository) Get(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error) {
m.mu.RLock()
defer m.mu.RUnlock()
prefs, ok := m.store[userID]
if !ok {
return nil, domain.ErrPreferencesNotFound
}
copy := *prefs
return &copy, nil
}
func (m *mockPreferencesRepository) Upsert(ctx context.Context, prefs *domain.UserPreferences) error {
m.mu.Lock()
defer m.mu.Unlock()
copy := *prefs
m.store[prefs.UserID] = &copy
return nil
}
func TestPreferencesService_GetPreferences(t *testing.T) {
repo := newMockPreferencesRepository()
svc := NewPreferencesService(repo, logging.Nop())
t.Run("returns defaults when no preferences exist", func(t *testing.T) {
prefs, err := svc.GetPreferences(context.Background(), "user-123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if prefs.UserID != "user-123" {
t.Errorf("expected user_id 'user-123', got '%s'", prefs.UserID)
}
if prefs.Preferences.Theme != "system" {
t.Errorf("expected default theme 'system', got '%s'", prefs.Preferences.Theme)
}
if prefs.Preferences.Language != "en" {
t.Errorf("expected default language 'en', got '%s'", prefs.Preferences.Language)
}
if !prefs.Preferences.Notifications.Email {
t.Error("expected default notifications.email to be true")
}
if !prefs.Preferences.Notifications.Push {
t.Error("expected default notifications.push to be true")
}
if prefs.Preferences.Notifications.SMS {
t.Error("expected default notifications.sms to be false")
}
})
t.Run("returns stored preferences", func(t *testing.T) {
// Store preferences first
_, err := svc.SetPreferences(context.Background(), "user-456", domain.Preferences{
Theme: "dark",
Language: "fr",
Notifications: domain.NotificationPreferences{
Email: false,
Push: true,
SMS: true,
},
})
if err != nil {
t.Fatalf("unexpected error setting preferences: %v", err)
}
prefs, err := svc.GetPreferences(context.Background(), "user-456")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if prefs.Preferences.Theme != "dark" {
t.Errorf("expected theme 'dark', got '%s'", prefs.Preferences.Theme)
}
if prefs.Preferences.Language != "fr" {
t.Errorf("expected language 'fr', got '%s'", prefs.Preferences.Language)
}
if prefs.Preferences.Notifications.Email {
t.Error("expected notifications.email to be false")
}
if prefs.Preferences.Notifications.SMS != true {
t.Error("expected notifications.sms to be true")
}
})
}
func TestPreferencesService_SetPreferences(t *testing.T) {
repo := newMockPreferencesRepository()
svc := NewPreferencesService(repo, logging.Nop())
t.Run("sets valid preferences", func(t *testing.T) {
prefs, err := svc.SetPreferences(context.Background(), "user-789", domain.Preferences{
Theme: "light",
Language: "en",
Notifications: domain.NotificationPreferences{
Email: true,
Push: false,
SMS: false,
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if prefs.UserID != "user-789" {
t.Errorf("expected user_id 'user-789', got '%s'", prefs.UserID)
}
if prefs.Preferences.Theme != "light" {
t.Errorf("expected theme 'light', got '%s'", prefs.Preferences.Theme)
}
if prefs.UpdatedAt.IsZero() {
t.Error("expected non-zero updated_at")
}
})
t.Run("rejects invalid theme", func(t *testing.T) {
_, err := svc.SetPreferences(context.Background(), "user-789", domain.Preferences{
Theme: "neon",
Language: "en",
})
if err != domain.ErrInvalidTheme {
t.Errorf("expected ErrInvalidTheme, got %v", err)
}
})
t.Run("rejects language exceeding max length", func(t *testing.T) {
_, err := svc.SetPreferences(context.Background(), "user-789", domain.Preferences{
Theme: "dark",
Language: "abcdefghijk", // 11 chars
})
if err != domain.ErrInvalidLanguage {
t.Errorf("expected ErrInvalidLanguage, got %v", err)
}
})
t.Run("allows empty theme", func(t *testing.T) {
prefs, err := svc.SetPreferences(context.Background(), "user-000", domain.Preferences{
Theme: "",
Language: "en",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if prefs.Preferences.Theme != "" {
t.Errorf("expected empty theme, got '%s'", prefs.Preferences.Theme)
}
})
t.Run("allows max length language", func(t *testing.T) {
_, err := svc.SetPreferences(context.Background(), "user-000", domain.Preferences{
Theme: "dark",
Language: "abcdefghij", // exactly 10 chars
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}

View File

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

View File

@ -0,0 +1,7 @@
// Package migrations embeds SQL migration files for the preferences-api service.
package migrations
import "embed"
//go:embed *.sql
var FS embed.FS

BIN
services/preferences-api/server Executable file

Binary file not shown.