build: /implement-feature user-preferences
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
This commit is contained in:
parent
3951ff5ed7
commit
73532902e7
@ -1,20 +1,36 @@
|
||||
slug: user-preferences
|
||||
title: User Preferences API
|
||||
created: 2026-02-08T05:49:49.197429066Z
|
||||
phase: draft
|
||||
phase: implementation
|
||||
phase_history:
|
||||
- phase: draft
|
||||
entered: 2026-02-08T05:49:49.197429066Z
|
||||
exited: 2026-02-08T06:04:08.481264669Z
|
||||
- phase: specified
|
||||
entered: 2026-02-08T06:04:08.481264669Z
|
||||
exited: 2026-02-08T06:04:08.489768009Z
|
||||
- phase: planned
|
||||
entered: 2026-02-08T06:04:08.489768009Z
|
||||
exited: 2026-02-08T06:04:08.49504538Z
|
||||
- phase: ready
|
||||
entered: 2026-02-08T06:04:08.49504538Z
|
||||
exited: 2026-02-08T06:04:08.50042898Z
|
||||
- phase: implementation
|
||||
entered: 2026-02-08T06:04:08.50042898Z
|
||||
artifacts:
|
||||
audit:
|
||||
status: pending
|
||||
path: audit.md
|
||||
design:
|
||||
status: draft
|
||||
status: approved
|
||||
path: design.md
|
||||
approved_by: user
|
||||
approved_at: 2026-02-08T06:04:02.519059418Z
|
||||
qa_plan:
|
||||
status: draft
|
||||
status: approved
|
||||
path: qa-plan.md
|
||||
approved_by: user
|
||||
approved_at: 2026-02-08T06:04:02.531924677Z
|
||||
qa_results:
|
||||
status: pending
|
||||
path: qa-results.md
|
||||
@ -22,34 +38,55 @@ artifacts:
|
||||
status: pending
|
||||
path: review.md
|
||||
spec:
|
||||
status: draft
|
||||
status: approved
|
||||
path: spec.md
|
||||
approved_by: user
|
||||
approved_at: 2026-02-08T06:04:02.513348292Z
|
||||
tasks:
|
||||
status: draft
|
||||
status: approved
|
||||
path: tasks.md
|
||||
approved_by: user
|
||||
approved_at: 2026-02-08T06:04:02.525504967Z
|
||||
total: 8
|
||||
completed: 8
|
||||
tasks:
|
||||
- id: task-001
|
||||
title: Domain layer - preference types, validation, defaults, and domain errors
|
||||
status: pending
|
||||
status: complete
|
||||
started_at: 2026-02-08T06:04:21.073131466Z
|
||||
done_at: 2026-02-08T06:04:59.143838086Z
|
||||
- id: task-002
|
||||
title: Port layer - PreferenceRepository interface and row type
|
||||
status: pending
|
||||
status: complete
|
||||
started_at: 2026-02-08T06:05:09.644910816Z
|
||||
done_at: 2026-02-08T06:05:19.519459876Z
|
||||
- id: task-003
|
||||
title: Service layer - PreferenceService with get, update, validation logic and unit tests
|
||||
status: pending
|
||||
status: complete
|
||||
started_at: 2026-02-08T06:05:27.120395886Z
|
||||
done_at: 2026-02-08T06:06:24.021112964Z
|
||||
- id: task-004
|
||||
title: Database migration - create user_preferences table
|
||||
status: pending
|
||||
status: complete
|
||||
started_at: 2026-02-08T06:06:35.339731999Z
|
||||
done_at: 2026-02-08T06:06:53.729592277Z
|
||||
- id: task-005
|
||||
title: PostgreSQL adapter - implement PreferenceRepository with sqlx
|
||||
status: pending
|
||||
status: complete
|
||||
started_at: 2026-02-08T06:07:01.795533407Z
|
||||
done_at: 2026-02-08T06:07:20.600911027Z
|
||||
- id: task-006
|
||||
title: Handler layer - GET and PUT preference handlers with error mapping and handler tests
|
||||
status: pending
|
||||
status: complete
|
||||
started_at: 2026-02-08T06:07:32.040998808Z
|
||||
done_at: 2026-02-08T06:08:37.691446229Z
|
||||
- id: task-007
|
||||
title: Routes, OpenAPI spec, and main.go wiring
|
||||
status: pending
|
||||
status: complete
|
||||
started_at: 2026-02-08T06:08:48.060805203Z
|
||||
done_at: 2026-02-08T06:10:41.696522126Z
|
||||
- id: task-008
|
||||
title: Cleanup - remove example scaffold files
|
||||
status: pending
|
||||
status: complete
|
||||
started_at: 2026-02-08T06:10:53.979045796Z
|
||||
done_at: 2026-02-08T06:11:23.894960337Z
|
||||
|
||||
@ -4,10 +4,10 @@ project:
|
||||
active_work:
|
||||
features:
|
||||
- slug: user-preferences
|
||||
phase: draft
|
||||
phase: implementation
|
||||
blocked: []
|
||||
last_updated: 2026-02-08T05:49:49.197770758Z
|
||||
last_action: CREATE_FEATURE
|
||||
last_updated: 2026-02-08T06:11:23.89581271Z
|
||||
last_action: COMPLETE_TASK
|
||||
last_actor: cli
|
||||
history:
|
||||
- timestamp: 2026-02-08T05:49:49.197770327Z
|
||||
@ -15,3 +15,83 @@ history:
|
||||
feature: user-preferences
|
||||
actor: cli
|
||||
result: success
|
||||
- timestamp: 2026-02-08T06:04:02.513963149Z
|
||||
action: APPROVE_ARTIFACT
|
||||
feature: user-preferences
|
||||
actor: user
|
||||
result: success
|
||||
- timestamp: 2026-02-08T06:04:02.519799782Z
|
||||
action: APPROVE_ARTIFACT
|
||||
feature: user-preferences
|
||||
actor: user
|
||||
result: success
|
||||
- timestamp: 2026-02-08T06:04:02.526201047Z
|
||||
action: APPROVE_ARTIFACT
|
||||
feature: user-preferences
|
||||
actor: user
|
||||
result: success
|
||||
- timestamp: 2026-02-08T06:04:02.534360671Z
|
||||
action: APPROVE_ARTIFACT
|
||||
feature: user-preferences
|
||||
actor: user
|
||||
result: success
|
||||
- timestamp: 2026-02-08T06:04:08.485187751Z
|
||||
action: TRANSITION
|
||||
feature: user-preferences
|
||||
actor: cli
|
||||
result: success
|
||||
- timestamp: 2026-02-08T06:04:08.490394658Z
|
||||
action: TRANSITION
|
||||
feature: user-preferences
|
||||
actor: cli
|
||||
result: success
|
||||
- timestamp: 2026-02-08T06:04:08.495775684Z
|
||||
action: TRANSITION
|
||||
feature: user-preferences
|
||||
actor: cli
|
||||
result: success
|
||||
- timestamp: 2026-02-08T06:04:08.501113688Z
|
||||
action: TRANSITION
|
||||
feature: user-preferences
|
||||
actor: cli
|
||||
result: success
|
||||
- timestamp: 2026-02-08T06:04:59.144474604Z
|
||||
action: COMPLETE_TASK
|
||||
feature: user-preferences
|
||||
actor: cli
|
||||
result: success
|
||||
- timestamp: 2026-02-08T06:05:19.520050407Z
|
||||
action: COMPLETE_TASK
|
||||
feature: user-preferences
|
||||
actor: cli
|
||||
result: success
|
||||
- timestamp: 2026-02-08T06:06:24.021723363Z
|
||||
action: COMPLETE_TASK
|
||||
feature: user-preferences
|
||||
actor: cli
|
||||
result: success
|
||||
- timestamp: 2026-02-08T06:06:53.730244845Z
|
||||
action: COMPLETE_TASK
|
||||
feature: user-preferences
|
||||
actor: cli
|
||||
result: success
|
||||
- timestamp: 2026-02-08T06:07:20.601540662Z
|
||||
action: COMPLETE_TASK
|
||||
feature: user-preferences
|
||||
actor: cli
|
||||
result: success
|
||||
- timestamp: 2026-02-08T06:08:37.692266683Z
|
||||
action: COMPLETE_TASK
|
||||
feature: user-preferences
|
||||
actor: cli
|
||||
result: success
|
||||
- timestamp: 2026-02-08T06:10:41.697480099Z
|
||||
action: COMPLETE_TASK
|
||||
feature: user-preferences
|
||||
actor: cli
|
||||
result: success
|
||||
- timestamp: 2026-02-08T06:11:23.895812069Z
|
||||
action: COMPLETE_TASK
|
||||
feature: user-preferences
|
||||
actor: cli
|
||||
result: success
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
// Package main is the entry point for the preferences-api service.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.threesix.ai/jordan/slack5-1770529463/pkg/app"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/pkg/database"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/pkg/logging"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/adapter/memory"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/adapter/postgres"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/api"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/config"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/migrations"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/service"
|
||||
)
|
||||
|
||||
@ -33,17 +36,39 @@ func main() {
|
||||
// Create logger
|
||||
logger := logging.Default()
|
||||
|
||||
// Load configuration
|
||||
cfg := config.Load()
|
||||
|
||||
// Connect to database
|
||||
ctx := context.Background()
|
||||
pool := database.MustConnect(ctx, cfg.Database.URL, database.Options{
|
||||
MaxOpenConns: cfg.Database.MaxOpenConns,
|
||||
MaxIdleConns: cfg.Database.MaxIdleConns,
|
||||
ConnMaxLifetime: cfg.Database.ConnMaxLifetime,
|
||||
})
|
||||
logger.Info("connected to database", "url", pool.URL)
|
||||
|
||||
// Run migrations
|
||||
database.MustRunMigrations(ctx, pool, migrations.FS, migrations.Dir)
|
||||
logger.Info("database migrations complete")
|
||||
|
||||
// Create adapters (repositories)
|
||||
exampleRepo := memory.NewExampleRepository()
|
||||
prefRepo := postgres.NewPreferenceRepository(pool.DB)
|
||||
|
||||
// Create services (business logic)
|
||||
exampleService := service.NewExampleService(exampleRepo, logger)
|
||||
preferenceService := service.NewPreferenceService(prefRepo, logger)
|
||||
|
||||
// Create application
|
||||
application := app.New("preferences-api", app.WithDefaultPort(8001))
|
||||
|
||||
// Register shutdown hook for database pool
|
||||
application.OnShutdown(func(ctx context.Context) error {
|
||||
logger.Info("closing database connection pool")
|
||||
return pool.Close()
|
||||
})
|
||||
|
||||
// Register routes with dependency injection
|
||||
api.RegisterRoutes(application, exampleService)
|
||||
api.RegisterRoutes(application, preferenceService)
|
||||
|
||||
// Start server
|
||||
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/slack5-1770529463/services/preferences-api/internal/domain"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/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,68 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port"
|
||||
)
|
||||
|
||||
// Compile-time verification that PreferenceRepository implements port.PreferenceRepository.
|
||||
var _ port.PreferenceRepository = (*PreferenceRepository)(nil)
|
||||
|
||||
// preferenceRow is the database representation of a preference row.
|
||||
type preferenceRow struct {
|
||||
UserID string `db:"user_id"`
|
||||
Key string `db:"key"`
|
||||
Value string `db:"value"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
// PreferenceRepository is a PostgreSQL implementation of port.PreferenceRepository.
|
||||
type PreferenceRepository struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewPreferenceRepository creates a new PostgreSQL preference repository.
|
||||
func NewPreferenceRepository(db *sqlx.DB) *PreferenceRepository {
|
||||
return &PreferenceRepository{db: db}
|
||||
}
|
||||
|
||||
// GetByUserID returns all stored preferences for a user.
|
||||
func (r *PreferenceRepository) GetByUserID(ctx context.Context, userID string) ([]port.PreferenceRow, error) {
|
||||
var rows []preferenceRow
|
||||
err := r.db.SelectContext(ctx, &rows,
|
||||
`SELECT user_id, key, value, created_at, updated_at FROM user_preferences WHERE user_id = $1`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]port.PreferenceRow, len(rows))
|
||||
for i, row := range rows {
|
||||
result[i] = port.PreferenceRow{
|
||||
UserID: row.UserID,
|
||||
Key: row.Key,
|
||||
Value: row.Value,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Upsert creates or updates a single preference for a user.
|
||||
func (r *PreferenceRepository) Upsert(ctx context.Context, userID string, key string, value string) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO user_preferences (user_id, key, value, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (user_id, key)
|
||||
DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
|
||||
userID, key, value,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@ -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-1770529463/pkg/app"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/pkg/httperror"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/pkg/logging"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/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/slack5-1770529463/pkg/logging"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
91
services/preferences-api/internal/api/handlers/preference.go
Normal file
91
services/preferences-api/internal/api/handlers/preference.go
Normal file
@ -0,0 +1,91 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.threesix.ai/jordan/slack5-1770529463/pkg/app"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/pkg/httperror"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/pkg/logging"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/service"
|
||||
)
|
||||
|
||||
// Preference handles HTTP requests for user preferences.
|
||||
type Preference struct {
|
||||
svc *service.PreferenceService
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewPreference creates a new Preference handler with injected dependencies.
|
||||
func NewPreference(svc *service.PreferenceService, logger *logging.Logger) *Preference {
|
||||
return &Preference{
|
||||
svc: svc,
|
||||
logger: logger.WithComponent("PreferenceHandler"),
|
||||
}
|
||||
}
|
||||
|
||||
// UpdatePreferencesRequest is the request body for updating preferences.
|
||||
type UpdatePreferencesRequest struct {
|
||||
Preferences map[string]any `json:"preferences"`
|
||||
}
|
||||
|
||||
// PreferencesResponse is the response for preference endpoints.
|
||||
type PreferencesResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
Preferences map[string]any `json:"preferences"`
|
||||
}
|
||||
|
||||
// GetPreferences returns all preferences for a user.
|
||||
func (h *Preference) GetPreferences(w http.ResponseWriter, r *http.Request) error {
|
||||
userID := chi.URLParam(r, "user_id")
|
||||
|
||||
result, err := h.svc.GetPreferences(r.Context(), userID)
|
||||
if err != nil {
|
||||
return mapPreferenceError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, PreferencesResponse{
|
||||
UserID: result.UserID,
|
||||
Preferences: result.Preferences,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdatePreferences creates or updates preferences for a user.
|
||||
func (h *Preference) UpdatePreferences(w http.ResponseWriter, r *http.Request) error {
|
||||
userID := chi.URLParam(r, "user_id")
|
||||
|
||||
var req UpdatePreferencesRequest
|
||||
if err := app.Bind(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := h.svc.UpdatePreferences(r.Context(), userID, req.Preferences)
|
||||
if err != nil {
|
||||
return mapPreferenceError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, PreferencesResponse{
|
||||
UserID: result.UserID,
|
||||
Preferences: result.Preferences,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// mapPreferenceError converts domain errors to HTTP errors.
|
||||
func mapPreferenceError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrInvalidUserID):
|
||||
return httperror.BadRequest("invalid user_id format: must be a valid UUID")
|
||||
case errors.Is(err, domain.ErrUnknownPreferenceKey):
|
||||
return httperror.BadRequest(err.Error())
|
||||
case errors.Is(err, domain.ErrInvalidPreferenceValue):
|
||||
return httperror.BadRequest(err.Error())
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,301 @@
|
||||
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-1770529463/pkg/app"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/pkg/logging"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/service"
|
||||
)
|
||||
|
||||
// mockPreferenceRepository implements port.PreferenceRepository for testing.
|
||||
type mockPreferenceRepository struct {
|
||||
mu sync.RWMutex
|
||||
prefs map[string]map[string]string
|
||||
}
|
||||
|
||||
var _ port.PreferenceRepository = (*mockPreferenceRepository)(nil)
|
||||
|
||||
func newMockPreferenceRepository() *mockPreferenceRepository {
|
||||
return &mockPreferenceRepository{
|
||||
prefs: make(map[string]map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockPreferenceRepository) GetByUserID(ctx context.Context, userID string) ([]port.PreferenceRow, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
userPrefs, ok := m.prefs[userID]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rows := make([]port.PreferenceRow, 0, len(userPrefs))
|
||||
for k, v := range userPrefs {
|
||||
rows = append(rows, port.PreferenceRow{
|
||||
UserID: userID,
|
||||
Key: k,
|
||||
Value: v,
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (m *mockPreferenceRepository) Upsert(ctx context.Context, userID string, key string, value string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.prefs[userID] == nil {
|
||||
m.prefs[userID] = make(map[string]string)
|
||||
}
|
||||
m.prefs[userID][key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestPreferenceHandler() (*Preference, *mockPreferenceRepository) {
|
||||
repo := newMockPreferenceRepository()
|
||||
svc := service.NewPreferenceService(repo, logging.Nop())
|
||||
handler := NewPreference(svc, logging.Nop())
|
||||
return handler, repo
|
||||
}
|
||||
|
||||
func TestPreference_GetPreferences(t *testing.T) {
|
||||
handler, repo := newTestPreferenceHandler()
|
||||
validUserID := "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
t.Run("returns defaults for new user", func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.GetPreferences))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/"+validUserID, 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"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected 'data' field as object in response")
|
||||
}
|
||||
|
||||
if data["user_id"] != validUserID {
|
||||
t.Errorf("expected user_id %s, got %v", validUserID, data["user_id"])
|
||||
}
|
||||
|
||||
prefs, ok := data["preferences"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected 'preferences' field as object in data")
|
||||
}
|
||||
|
||||
if prefs["theme"] != "system" {
|
||||
t.Errorf("expected default theme 'system', got %v", prefs["theme"])
|
||||
}
|
||||
if prefs["language"] != "en" {
|
||||
t.Errorf("expected default language 'en', got %v", prefs["language"])
|
||||
}
|
||||
if prefs["notifications_enabled"] != true {
|
||||
t.Errorf("expected default notifications_enabled true, got %v", prefs["notifications_enabled"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns stored preferences merged with defaults", func(t *testing.T) {
|
||||
_ = repo.Upsert(context.Background(), validUserID, "theme", "dark")
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.GetPreferences))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/"+validUserID, 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
|
||||
_ = json.NewDecoder(w.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"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns 400 for invalid user_id", func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.GetPreferences))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/not-a-uuid", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("response has meta field", func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.GetPreferences))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/"+validUserID, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
var resp map[string]any
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if _, ok := resp["meta"]; !ok {
|
||||
t.Error("expected 'meta' field in response")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPreference_UpdatePreferences(t *testing.T) {
|
||||
handler, _ := newTestPreferenceHandler()
|
||||
validUserID := "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
t.Run("updates preferences successfully", func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.UpdatePreferences))
|
||||
|
||||
body, _ := json.Marshal(UpdatePreferencesRequest{
|
||||
Preferences: map[string]any{
|
||||
"theme": "dark",
|
||||
"language": "fr",
|
||||
},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+validUserID, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
_ = json.NewDecoder(w.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"] != "fr" {
|
||||
t.Errorf("expected language 'fr', got %v", prefs["language"])
|
||||
}
|
||||
// Default should still be present
|
||||
if prefs["notifications_enabled"] != true {
|
||||
t.Errorf("expected notifications_enabled true, got %v", prefs["notifications_enabled"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns 400 for unknown key", func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.UpdatePreferences))
|
||||
|
||||
body, _ := json.Marshal(UpdatePreferencesRequest{
|
||||
Preferences: map[string]any{
|
||||
"font_size": "large",
|
||||
},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+validUserID, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns 400 for invalid value", func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.UpdatePreferences))
|
||||
|
||||
body, _ := json.Marshal(UpdatePreferencesRequest{
|
||||
Preferences: map[string]any{
|
||||
"theme": "neon",
|
||||
},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+validUserID, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns 400 for invalid user_id", func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.UpdatePreferences))
|
||||
|
||||
body, _ := json.Marshal(UpdatePreferencesRequest{
|
||||
Preferences: map[string]any{
|
||||
"theme": "dark",
|
||||
},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/bad-id", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handles boolean notifications_enabled", func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.UpdatePreferences))
|
||||
|
||||
body, _ := json.Marshal(UpdatePreferencesRequest{
|
||||
Preferences: map[string]any{
|
||||
"notifications_enabled": false,
|
||||
},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+validUserID, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
data := resp["data"].(map[string]any)
|
||||
prefs := data["preferences"].(map[string]any)
|
||||
|
||||
if prefs["notifications_enabled"] != false {
|
||||
t.Errorf("expected notifications_enabled false, got %v", prefs["notifications_enabled"])
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
// Package api provides HTTP routing and handlers for the preferences-api service.
|
||||
package api
|
||||
|
||||
import (
|
||||
@ -10,32 +9,23 @@ import (
|
||||
)
|
||||
|
||||
// RegisterRoutes registers all HTTP routes for the service.
|
||||
// Routes are mounted under /api/preferences-api to match the ingress path routing.
|
||||
// This allows the monorepo to expose multiple services under a single domain:
|
||||
// - https://domain/api/preferences-api/health
|
||||
// - https://domain/api/preferences-api/examples
|
||||
func RegisterRoutes(application *app.App, exampleService *service.ExampleService) {
|
||||
func RegisterRoutes(application *app.App, preferenceService *service.PreferenceService) {
|
||||
logger := application.Logger()
|
||||
cfg := config.Load()
|
||||
|
||||
// Initialize handlers with injected services
|
||||
healthHandler := handlers.NewHealth(logger)
|
||||
exampleHandler := handlers.NewExample(exampleService, logger)
|
||||
prefHandler := handlers.NewPreference(preferenceService, logger)
|
||||
|
||||
// Build and mount OpenAPI spec
|
||||
spec := NewServiceSpec()
|
||||
application.EnableDocs(spec)
|
||||
|
||||
// Register API routes under /api/{service-name} to match ingress path routing.
|
||||
// The ingress routes /api/preferences-api/* to this service.
|
||||
application.Route("/api/preferences-api", func(r app.Router) {
|
||||
r.Get("/health", healthHandler.Check)
|
||||
|
||||
// Public routes (no auth required)
|
||||
r.Get("/examples", app.Wrap(exampleHandler.List))
|
||||
r.Get("/examples/{id}", app.Wrap(exampleHandler.Get))
|
||||
|
||||
// Protected routes (auth required when enabled)
|
||||
// Preferences routes (auth-protectable)
|
||||
r.Group(func(r app.Router) {
|
||||
if cfg.AuthEnabled {
|
||||
r.Use(auth.Middleware(auth.MiddlewareConfig{
|
||||
@ -46,9 +36,8 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
|
||||
}))
|
||||
}
|
||||
|
||||
r.Post("/examples", app.Wrap(exampleHandler.Create))
|
||||
r.Put("/examples/{id}", app.Wrap(exampleHandler.Update))
|
||||
r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete))
|
||||
r.Get("/preferences/{user_id}", app.Wrap(prefHandler.GetPreferences))
|
||||
r.Put("/preferences/{user_id}", app.Wrap(prefHandler.UpdatePreferences))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@ -5,29 +5,28 @@ import "git.threesix.ai/jordan/slack5-1770529463/pkg/openapi"
|
||||
// NewServiceSpec builds the OpenAPI specification for the preferences-api service.
|
||||
func NewServiceSpec() *openapi.OpenAPISpec {
|
||||
spec := openapi.NewOpenAPISpec("preferences-api API", "1.0.0").
|
||||
WithDescription("REST API for the preferences-api service").
|
||||
WithDescription("REST API for managing user preferences").
|
||||
WithBearerSecurity("bearer", "JWT authentication token").
|
||||
WithTag("Health", "Service health endpoints").
|
||||
WithTag("Examples", "Example CRUD endpoints")
|
||||
WithTag("Preferences", "User preference endpoints")
|
||||
|
||||
// Define reusable schemas
|
||||
spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{
|
||||
"id": openapi.UUID().WithDescription("Unique identifier"),
|
||||
"name": openapi.String().WithDescription("Name of the example").WithExample("My Example"),
|
||||
"description": openapi.String().WithDescription("Optional description").WithExample("A description"),
|
||||
"created_at": openapi.DateTime().WithDescription("Creation timestamp"),
|
||||
"updated_at": openapi.DateTime().WithDescription("Last update timestamp"),
|
||||
}, "id", "name"))
|
||||
spec.WithSchema("UserPreferences", 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").WithDefault("system"),
|
||||
"language": openapi.String().WithDescription("BCP 47 language tag").WithExample("en"),
|
||||
"notifications_enabled": openapi.Bool().WithDescription("Whether notifications are enabled").WithDefault(true),
|
||||
}),
|
||||
}, "user_id", "preferences"))
|
||||
|
||||
spec.WithSchema("CreateExampleRequest", openapi.Object(map[string]openapi.Schema{
|
||||
"name": openapi.StringWithMinMax(1, 100).WithDescription("Name of the example"),
|
||||
"description": openapi.StringWithMinMax(0, 500).WithDescription("Optional description"),
|
||||
}, "name"))
|
||||
|
||||
spec.WithSchema("UpdateExampleRequest", openapi.Object(map[string]openapi.Schema{
|
||||
"name": openapi.StringWithMinMax(1, 100).WithDescription("Updated name"),
|
||||
"description": openapi.StringWithMinMax(0, 500).WithDescription("Updated description"),
|
||||
}))
|
||||
spec.WithSchema("UpdatePreferencesRequest", openapi.Object(map[string]openapi.Schema{
|
||||
"preferences": openapi.Object(map[string]openapi.Schema{
|
||||
"theme": openapi.StringEnum("light", "dark", "system").WithDescription("UI theme"),
|
||||
"language": openapi.String().WithDescription("BCP 47 language tag").WithExample("fr"),
|
||||
"notifications_enabled": openapi.Bool().WithDescription("Whether notifications are enabled"),
|
||||
}),
|
||||
}, "preferences"))
|
||||
|
||||
// Health
|
||||
spec.AddPath("/api/preferences-api/health", "get", map[string]any{
|
||||
@ -41,70 +40,30 @@ func NewServiceSpec() *openapi.OpenAPISpec {
|
||||
},
|
||||
})
|
||||
|
||||
// List examples
|
||||
spec.AddPath("/api/preferences-api/examples", "get", map[string]any{
|
||||
"summary": "List examples",
|
||||
"description": "Returns a paginated list of examples.",
|
||||
"tags": []string{"Examples"},
|
||||
"parameters": []any{openapi.PageParam(), openapi.PerPageParam()},
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.RefArray("Example"))),
|
||||
},
|
||||
})
|
||||
|
||||
// Get example
|
||||
spec.AddPath("/api/preferences-api/examples/{id}", "get", map[string]any{
|
||||
"summary": "Get example by ID",
|
||||
"tags": []string{"Examples"},
|
||||
"parameters": []any{openapi.IDParam()},
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("Example"))),
|
||||
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
// Create example
|
||||
spec.AddPath("/api/preferences-api/examples", "post", map[string]any{
|
||||
"summary": "Create example",
|
||||
"description": "Creates a new example. Requires authentication.",
|
||||
"tags": []string{"Examples"},
|
||||
// Get preferences
|
||||
spec.AddPath("/api/preferences-api/preferences/{user_id}", "get", map[string]any{
|
||||
"summary": "Get user preferences",
|
||||
"description": "Returns all preferences for a user, merging stored values with server-defined defaults.",
|
||||
"tags": []string{"Preferences"},
|
||||
"security": []map[string][]string{{"bearer": {}}},
|
||||
"requestBody": openapi.RequestBody(openapi.Ref("CreateExampleRequest"), true),
|
||||
"parameters": []any{openapi.PathParamWithSchema("user_id", "User identifier (UUID)", openapi.UUID())},
|
||||
"responses": map[string]any{
|
||||
"201": openapi.OpResponse("Created", openapi.ResponseSchema(openapi.Ref("Example"))),
|
||||
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
|
||||
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
|
||||
"422": openapi.OpResponse("Validation error", openapi.ErrorResponseSchema()),
|
||||
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("UserPreferences"))),
|
||||
"400": openapi.OpResponse("Invalid user_id format", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
// Update example
|
||||
spec.AddPath("/api/preferences-api/examples/{id}", "put", map[string]any{
|
||||
"summary": "Update example",
|
||||
"description": "Updates an existing example. Requires authentication.",
|
||||
"tags": []string{"Examples"},
|
||||
// Update preferences
|
||||
spec.AddPath("/api/preferences-api/preferences/{user_id}", "put", map[string]any{
|
||||
"summary": "Update user preferences",
|
||||
"description": "Creates or updates preferences for the given user. Only provided keys are updated; omitted keys retain their current value or default.",
|
||||
"tags": []string{"Preferences"},
|
||||
"security": []map[string][]string{{"bearer": {}}},
|
||||
"parameters": []any{openapi.IDParam()},
|
||||
"requestBody": openapi.RequestBody(openapi.Ref("UpdateExampleRequest"), true),
|
||||
"parameters": []any{openapi.PathParamWithSchema("user_id", "User identifier (UUID)", openapi.UUID())},
|
||||
"requestBody": openapi.RequestBody(openapi.Ref("UpdatePreferencesRequest"), 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()),
|
||||
"200": openapi.OpResponse("Updated", openapi.ResponseSchema(openapi.Ref("UserPreferences"))),
|
||||
"400": openapi.OpResponse("Bad request (invalid user_id, unknown key, or invalid value)", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -1,21 +1,15 @@
|
||||
// Package domain contains pure domain models with no external dependencies.
|
||||
// These types represent the core business concepts of the service.
|
||||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
// Domain errors - these are business-level errors that should be translated
|
||||
// to appropriate HTTP status codes by the handler layer.
|
||||
// Domain errors for user preferences.
|
||||
var (
|
||||
// ErrNotFound indicates a requested resource does not exist.
|
||||
ErrNotFound = errors.New("not found")
|
||||
// ErrUnknownPreferenceKey indicates an unknown preference key was provided.
|
||||
ErrUnknownPreferenceKey = errors.New("unknown preference key")
|
||||
|
||||
// ErrExampleNotFound indicates the requested example does not exist.
|
||||
ErrExampleNotFound = errors.New("example not found")
|
||||
// ErrInvalidPreferenceValue indicates a preference value failed validation.
|
||||
ErrInvalidPreferenceValue = errors.New("invalid preference value")
|
||||
|
||||
// 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")
|
||||
// ErrInvalidUserID indicates the user_id is not a valid UUID.
|
||||
ErrInvalidUserID = errors.New("invalid user id")
|
||||
)
|
||||
|
||||
@ -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
|
||||
}
|
||||
133
services/preferences-api/internal/domain/preference.go
Normal file
133
services/preferences-api/internal/domain/preference.go
Normal file
@ -0,0 +1,133 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// PreferenceKey is a typed key for a known preference.
|
||||
type PreferenceKey string
|
||||
|
||||
const (
|
||||
KeyTheme PreferenceKey = "theme"
|
||||
KeyLanguage PreferenceKey = "language"
|
||||
KeyNotificationsEnabled PreferenceKey = "notifications_enabled"
|
||||
)
|
||||
|
||||
// PreferenceDefinition describes a known preference key with its default and validator.
|
||||
type PreferenceDefinition struct {
|
||||
Key PreferenceKey
|
||||
DefaultValue string
|
||||
Validate func(value string) error
|
||||
}
|
||||
|
||||
// UserPreferences is the aggregate representing all preferences for a user.
|
||||
type UserPreferences struct {
|
||||
UserID string
|
||||
Preferences map[PreferenceKey]string
|
||||
}
|
||||
|
||||
// uuidRegex validates UUID format.
|
||||
var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||
|
||||
// bcp47Regex validates BCP 47 language tags.
|
||||
var bcp47Regex = regexp.MustCompile(`^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{1,8})*$`)
|
||||
|
||||
// validThemes is the set of allowed theme values.
|
||||
var validThemes = map[string]bool{
|
||||
"light": true,
|
||||
"dark": true,
|
||||
"system": true,
|
||||
}
|
||||
|
||||
// registry holds the definitions for all known preference keys.
|
||||
var registry = map[PreferenceKey]PreferenceDefinition{
|
||||
KeyTheme: {
|
||||
Key: KeyTheme,
|
||||
DefaultValue: "system",
|
||||
Validate: func(value string) error {
|
||||
if !validThemes[value] {
|
||||
return fmt.Errorf("%w: theme must be one of: light, dark, system", ErrInvalidPreferenceValue)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
KeyLanguage: {
|
||||
Key: KeyLanguage,
|
||||
DefaultValue: "en",
|
||||
Validate: func(value string) error {
|
||||
if !bcp47Regex.MatchString(value) {
|
||||
return fmt.Errorf("%w: language must be a valid BCP 47 tag", ErrInvalidPreferenceValue)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
KeyNotificationsEnabled: {
|
||||
Key: KeyNotificationsEnabled,
|
||||
DefaultValue: "true",
|
||||
Validate: func(value string) error {
|
||||
if value != "true" && value != "false" {
|
||||
return fmt.Errorf("%w: notifications_enabled must be true or false", ErrInvalidPreferenceValue)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ValidateUserID checks that userID is a valid UUID.
|
||||
func ValidateUserID(userID string) error {
|
||||
if !uuidRegex.MatchString(userID) {
|
||||
return ErrInvalidUserID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateKey checks that key is a known preference key.
|
||||
func ValidateKey(key string) error {
|
||||
if _, ok := registry[PreferenceKey(key)]; !ok {
|
||||
return fmt.Errorf("%w: %s", ErrUnknownPreferenceKey, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateValue validates a value for the given preference key.
|
||||
func ValidateValue(key PreferenceKey, value string) error {
|
||||
def, ok := registry[key]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrUnknownPreferenceKey, key)
|
||||
}
|
||||
return def.Validate(value)
|
||||
}
|
||||
|
||||
// DefaultPreferences returns all known keys with their default values.
|
||||
func DefaultPreferences() map[PreferenceKey]string {
|
||||
defaults := make(map[PreferenceKey]string, len(registry))
|
||||
for k, def := range registry {
|
||||
defaults[k] = def.DefaultValue
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
|
||||
// MergeWithDefaults fills in missing keys from defaults, preserving stored values.
|
||||
func MergeWithDefaults(stored map[PreferenceKey]string) map[PreferenceKey]string {
|
||||
merged := DefaultPreferences()
|
||||
for k, v := range stored {
|
||||
merged[k] = v
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// SerializeForResponse converts stored string values to typed values for JSON.
|
||||
// notifications_enabled "true"/"false" becomes boolean true/false.
|
||||
func SerializeForResponse(prefs map[PreferenceKey]string) map[string]any {
|
||||
result := make(map[string]any, len(prefs))
|
||||
for k, v := range prefs {
|
||||
switch k {
|
||||
case KeyNotificationsEnabled:
|
||||
result[string(k)] = v == "true"
|
||||
default:
|
||||
result[string(k)] = v
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
9
services/preferences-api/internal/migrations/embed.go
Normal file
9
services/preferences-api/internal/migrations/embed.go
Normal file
@ -0,0 +1,9 @@
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed sql/*.sql
|
||||
var FS embed.FS
|
||||
|
||||
// Dir is the directory within the embedded filesystem containing migration files.
|
||||
const Dir = "sql"
|
||||
@ -0,0 +1,10 @@
|
||||
CREATE TABLE user_preferences (
|
||||
user_id UUID NOT NULL,
|
||||
key VARCHAR(64) NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_preferences_user_id ON user_preferences (user_id);
|
||||
@ -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-1770529463/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)
|
||||
}
|
||||
25
services/preferences-api/internal/port/preference.go
Normal file
25
services/preferences-api/internal/port/preference.go
Normal file
@ -0,0 +1,25 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PreferenceRow represents a single preference row from the database.
|
||||
type PreferenceRow struct {
|
||||
UserID string
|
||||
Key string
|
||||
Value string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// PreferenceRepository defines the interface for preference persistence operations.
|
||||
type PreferenceRepository interface {
|
||||
// GetByUserID returns all stored preferences for a user.
|
||||
// Returns an empty slice (not an error) if no preferences exist.
|
||||
GetByUserID(ctx context.Context, userID string) ([]PreferenceRow, error)
|
||||
|
||||
// Upsert creates or updates a single preference for a user.
|
||||
Upsert(ctx context.Context, userID string, key string, value string) 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/slack5-1770529463/pkg/logging"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/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/slack5-1770529463/pkg/logging"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/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))
|
||||
}
|
||||
})
|
||||
}
|
||||
119
services/preferences-api/internal/service/preference.go
Normal file
119
services/preferences-api/internal/service/preference.go
Normal file
@ -0,0 +1,119 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.threesix.ai/jordan/slack5-1770529463/pkg/logging"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port"
|
||||
)
|
||||
|
||||
// PreferencesResult is the result of a preference operation.
|
||||
type PreferencesResult struct {
|
||||
UserID string
|
||||
Preferences map[string]any
|
||||
}
|
||||
|
||||
// PreferenceService handles preference business logic.
|
||||
type PreferenceService struct {
|
||||
repo port.PreferenceRepository
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewPreferenceService creates a new preference service.
|
||||
func NewPreferenceService(repo port.PreferenceRepository, logger *logging.Logger) *PreferenceService {
|
||||
return &PreferenceService{
|
||||
repo: repo,
|
||||
logger: logger.WithService("PreferenceService"),
|
||||
}
|
||||
}
|
||||
|
||||
// GetPreferences returns all preferences for a user, merged with defaults.
|
||||
func (s *PreferenceService) GetPreferences(ctx context.Context, userID string) (*PreferencesResult, error) {
|
||||
if err := domain.ValidateUserID(userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := s.repo.GetByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stored := make(map[domain.PreferenceKey]string, len(rows))
|
||||
for _, row := range rows {
|
||||
stored[domain.PreferenceKey(row.Key)] = row.Value
|
||||
}
|
||||
|
||||
merged := domain.MergeWithDefaults(stored)
|
||||
serialized := domain.SerializeForResponse(merged)
|
||||
|
||||
return &PreferencesResult{
|
||||
UserID: userID,
|
||||
Preferences: serialized,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdatePreferences validates and upserts the provided preferences, then returns the full merged result.
|
||||
func (s *PreferenceService) UpdatePreferences(ctx context.Context, userID string, input map[string]any) (*PreferencesResult, error) {
|
||||
if err := domain.ValidateUserID(userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate and serialize each input key-value pair
|
||||
toUpsert := make(map[domain.PreferenceKey]string, len(input))
|
||||
for key, val := range input {
|
||||
if err := domain.ValidateKey(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Serialize value to string
|
||||
strVal, err := serializeValue(val)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s must have a valid value", domain.ErrInvalidPreferenceValue, key)
|
||||
}
|
||||
|
||||
pk := domain.PreferenceKey(key)
|
||||
if err := domain.ValidateValue(pk, strVal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toUpsert[pk] = strVal
|
||||
}
|
||||
|
||||
// Upsert each preference
|
||||
for key, value := range toUpsert {
|
||||
if err := s.repo.Upsert(ctx, userID, string(key), value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info("preferences updated", "user_id", userID, "keys_updated", len(toUpsert))
|
||||
|
||||
// Fetch and return full merged preferences
|
||||
return s.GetPreferences(ctx, userID)
|
||||
}
|
||||
|
||||
// serializeValue converts an any value to a string representation.
|
||||
func serializeValue(val any) (string, error) {
|
||||
switch v := val.(type) {
|
||||
case string:
|
||||
return v, nil
|
||||
case bool:
|
||||
if v {
|
||||
return "true", nil
|
||||
}
|
||||
return "false", nil
|
||||
case float64:
|
||||
// JSON numbers are decoded as float64
|
||||
if v == 1 {
|
||||
return "true", nil
|
||||
}
|
||||
if v == 0 {
|
||||
return "false", nil
|
||||
}
|
||||
return "", fmt.Errorf("unsupported numeric value")
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported value type %T", val)
|
||||
}
|
||||
}
|
||||
253
services/preferences-api/internal/service/preference_test.go
Normal file
253
services/preferences-api/internal/service/preference_test.go
Normal file
@ -0,0 +1,253 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.threesix.ai/jordan/slack5-1770529463/pkg/logging"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain"
|
||||
"git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port"
|
||||
)
|
||||
|
||||
// mockPreferenceRepository implements port.PreferenceRepository for testing.
|
||||
type mockPreferenceRepository struct {
|
||||
mu sync.RWMutex
|
||||
prefs map[string]map[string]string // userID -> key -> value
|
||||
}
|
||||
|
||||
var _ port.PreferenceRepository = (*mockPreferenceRepository)(nil)
|
||||
|
||||
func newMockPreferenceRepository() *mockPreferenceRepository {
|
||||
return &mockPreferenceRepository{
|
||||
prefs: make(map[string]map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockPreferenceRepository) GetByUserID(ctx context.Context, userID string) ([]port.PreferenceRow, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
userPrefs, ok := m.prefs[userID]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rows := make([]port.PreferenceRow, 0, len(userPrefs))
|
||||
for k, v := range userPrefs {
|
||||
rows = append(rows, port.PreferenceRow{
|
||||
UserID: userID,
|
||||
Key: k,
|
||||
Value: v,
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (m *mockPreferenceRepository) Upsert(ctx context.Context, userID string, key string, value string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.prefs[userID] == nil {
|
||||
m.prefs[userID] = make(map[string]string)
|
||||
}
|
||||
m.prefs[userID][key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPreferenceService_GetPreferences(t *testing.T) {
|
||||
repo := newMockPreferenceRepository()
|
||||
svc := NewPreferenceService(repo, logging.Nop())
|
||||
ctx := context.Background()
|
||||
validUserID := "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
t.Run("returns defaults when no preferences stored", func(t *testing.T) {
|
||||
result, err := svc.GetPreferences(ctx, validUserID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.UserID != validUserID {
|
||||
t.Errorf("expected user_id %s, got %s", validUserID, result.UserID)
|
||||
}
|
||||
if result.Preferences["theme"] != "system" {
|
||||
t.Errorf("expected default theme 'system', got %v", result.Preferences["theme"])
|
||||
}
|
||||
if result.Preferences["language"] != "en" {
|
||||
t.Errorf("expected default language 'en', got %v", result.Preferences["language"])
|
||||
}
|
||||
if result.Preferences["notifications_enabled"] != true {
|
||||
t.Errorf("expected default notifications_enabled true, got %v", result.Preferences["notifications_enabled"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("merges stored values with defaults", func(t *testing.T) {
|
||||
// Store only theme
|
||||
_ = repo.Upsert(ctx, validUserID, "theme", "dark")
|
||||
|
||||
result, err := svc.GetPreferences(ctx, validUserID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Preferences["theme"] != "dark" {
|
||||
t.Errorf("expected theme 'dark', got %v", result.Preferences["theme"])
|
||||
}
|
||||
if result.Preferences["language"] != "en" {
|
||||
t.Errorf("expected default language 'en', got %v", result.Preferences["language"])
|
||||
}
|
||||
if result.Preferences["notifications_enabled"] != true {
|
||||
t.Errorf("expected default notifications_enabled true, got %v", result.Preferences["notifications_enabled"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns all stored values when all keys present", func(t *testing.T) {
|
||||
userID := "660e8400-e29b-41d4-a716-446655440000"
|
||||
_ = repo.Upsert(ctx, userID, "theme", "light")
|
||||
_ = repo.Upsert(ctx, userID, "language", "fr")
|
||||
_ = repo.Upsert(ctx, userID, "notifications_enabled", "false")
|
||||
|
||||
result, err := svc.GetPreferences(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Preferences["theme"] != "light" {
|
||||
t.Errorf("expected theme 'light', got %v", result.Preferences["theme"])
|
||||
}
|
||||
if result.Preferences["language"] != "fr" {
|
||||
t.Errorf("expected language 'fr', got %v", result.Preferences["language"])
|
||||
}
|
||||
if result.Preferences["notifications_enabled"] != false {
|
||||
t.Errorf("expected notifications_enabled false, got %v", result.Preferences["notifications_enabled"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns error for invalid user_id", func(t *testing.T) {
|
||||
_, err := svc.GetPreferences(ctx, "not-a-uuid")
|
||||
if err != domain.ErrInvalidUserID {
|
||||
t.Errorf("expected ErrInvalidUserID, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPreferenceService_UpdatePreferences(t *testing.T) {
|
||||
repo := newMockPreferenceRepository()
|
||||
svc := NewPreferenceService(repo, logging.Nop())
|
||||
ctx := context.Background()
|
||||
validUserID := "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
t.Run("updates preferences and returns full result", func(t *testing.T) {
|
||||
result, err := svc.UpdatePreferences(ctx, validUserID, map[string]any{
|
||||
"theme": "dark",
|
||||
"language": "fr",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Preferences["theme"] != "dark" {
|
||||
t.Errorf("expected theme 'dark', got %v", result.Preferences["theme"])
|
||||
}
|
||||
if result.Preferences["language"] != "fr" {
|
||||
t.Errorf("expected language 'fr', got %v", result.Preferences["language"])
|
||||
}
|
||||
// notifications_enabled should be default
|
||||
if result.Preferences["notifications_enabled"] != true {
|
||||
t.Errorf("expected default notifications_enabled true, got %v", result.Preferences["notifications_enabled"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handles boolean input for notifications_enabled", func(t *testing.T) {
|
||||
result, err := svc.UpdatePreferences(ctx, validUserID, map[string]any{
|
||||
"notifications_enabled": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Preferences["notifications_enabled"] != false {
|
||||
t.Errorf("expected notifications_enabled false, got %v", result.Preferences["notifications_enabled"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects unknown key", func(t *testing.T) {
|
||||
_, err := svc.UpdatePreferences(ctx, validUserID, map[string]any{
|
||||
"font_size": "large",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown key")
|
||||
}
|
||||
// Should wrap ErrUnknownPreferenceKey
|
||||
if !containsError(err, domain.ErrUnknownPreferenceKey) {
|
||||
t.Errorf("expected ErrUnknownPreferenceKey, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects invalid theme value", func(t *testing.T) {
|
||||
_, err := svc.UpdatePreferences(ctx, validUserID, map[string]any{
|
||||
"theme": "neon",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid theme")
|
||||
}
|
||||
if !containsError(err, domain.ErrInvalidPreferenceValue) {
|
||||
t.Errorf("expected ErrInvalidPreferenceValue, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects invalid language value", func(t *testing.T) {
|
||||
_, err := svc.UpdatePreferences(ctx, validUserID, map[string]any{
|
||||
"language": "123",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid language")
|
||||
}
|
||||
if !containsError(err, domain.ErrInvalidPreferenceValue) {
|
||||
t.Errorf("expected ErrInvalidPreferenceValue, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects invalid user_id", func(t *testing.T) {
|
||||
_, err := svc.UpdatePreferences(ctx, "bad-id", map[string]any{
|
||||
"theme": "dark",
|
||||
})
|
||||
if err != domain.ErrInvalidUserID {
|
||||
t.Errorf("expected ErrInvalidUserID, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("is idempotent", func(t *testing.T) {
|
||||
userID := "770e8400-e29b-41d4-a716-446655440000"
|
||||
input := map[string]any{"theme": "dark"}
|
||||
|
||||
result1, err := svc.UpdatePreferences(ctx, userID, input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
result2, err := svc.UpdatePreferences(ctx, userID, input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result1.Preferences["theme"] != result2.Preferences["theme"] {
|
||||
t.Errorf("expected idempotent result, got %v and %v", result1.Preferences["theme"], result2.Preferences["theme"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// containsError checks if err wraps target using errors.Is-like behavior with fmt.Errorf wrapping.
|
||||
func containsError(err, target error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
for e := err; e != nil; {
|
||||
if e == target {
|
||||
return true
|
||||
}
|
||||
if e.Error() == target.Error() {
|
||||
return true
|
||||
}
|
||||
u, ok := e.(interface{ Unwrap() error })
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
e = u.Unwrap()
|
||||
}
|
||||
return false
|
||||
}
|
||||
BIN
services/preferences-api/server
Executable file
BIN
services/preferences-api/server
Executable file
Binary file not shown.
Loading…
Reference in New Issue
Block a user