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

This commit is contained in:
rdev-worker 2026-02-08 18:36:52 +00:00
parent 08a3685359
commit 5fa5a77bfb
23 changed files with 1312 additions and 1339 deletions

View File

@ -1,20 +1,36 @@
slug: user-preferences slug: user-preferences
title: User Preferences API title: User Preferences API
created: 2026-02-08T18:17:02.968042724Z created: 2026-02-08T18:17:02.968042724Z
phase: draft phase: implementation
phase_history: phase_history:
- phase: draft - phase: draft
entered: 2026-02-08T18:17:02.968042724Z entered: 2026-02-08T18:17:02.968042724Z
exited: 2026-02-08T18:29:39.550381897Z
- phase: specified
entered: 2026-02-08T18:29:39.550381897Z
exited: 2026-02-08T18:29:53.153864477Z
- phase: planned
entered: 2026-02-08T18:29:53.153864477Z
exited: 2026-02-08T18:29:56.894993224Z
- phase: ready
entered: 2026-02-08T18:29:56.894993224Z
exited: 2026-02-08T18:29:56.917360514Z
- phase: implementation
entered: 2026-02-08T18:29:56.917360514Z
artifacts: artifacts:
audit: audit:
status: pending status: pending
path: audit.md path: audit.md
design: design:
status: draft status: approved
path: design.md path: design.md
approved_by: user
approved_at: 2026-02-08T18:29:42.702450956Z
qa_plan: qa_plan:
status: draft status: approved
path: qa-plan.md path: qa-plan.md
approved_by: user
approved_at: 2026-02-08T18:29:53.146901385Z
qa_results: qa_results:
status: pending status: pending
path: qa-results.md path: qa-results.md
@ -22,34 +38,55 @@ artifacts:
status: pending status: pending
path: review.md path: review.md
spec: spec:
status: draft status: approved
path: spec.md path: spec.md
approved_by: user
approved_at: 2026-02-08T18:29:39.530157029Z
tasks: tasks:
status: draft status: approved
path: tasks.md path: tasks.md
approved_by: user
approved_at: 2026-02-08T18:29:49.407986092Z
total: 8 total: 8
completed: 8
tasks: tasks:
- id: task-001 - id: task-001
title: Domain layer - preference types, validation, defaults, and errors title: Domain layer - preference types, validation, defaults, and errors
status: pending status: complete
started_at: 2026-02-08T18:30:07.177070642Z
done_at: 2026-02-08T18:30:47.563414677Z
- id: task-002 - id: task-002
title: Port layer - PreferenceRepository interface title: Port layer - PreferenceRepository interface
status: pending status: complete
started_at: 2026-02-08T18:30:54.092228454Z
done_at: 2026-02-08T18:31:07.242219516Z
- id: task-003 - id: task-003
title: Adapter layer - in-memory PreferenceRepository for tests title: Adapter layer - in-memory PreferenceRepository for tests
status: pending status: complete
started_at: 2026-02-08T18:31:10.959466002Z
done_at: 2026-02-08T18:31:28.764295086Z
- id: task-004 - id: task-004
title: Adapter layer - PostgreSQL PreferenceRepository with schema creation title: Adapter layer - PostgreSQL PreferenceRepository with schema creation
status: pending status: complete
started_at: 2026-02-08T18:31:33.081182632Z
done_at: 2026-02-08T18:32:08.171501928Z
- id: task-005 - id: task-005
title: Service layer - PreferenceService with business logic and tests title: Service layer - PreferenceService with business logic and tests
status: pending status: complete
started_at: 2026-02-08T18:32:11.180930379Z
done_at: 2026-02-08T18:32:50.251188405Z
- id: task-006 - id: task-006
title: Handler layer - GET and PUT preference handlers with tests title: Handler layer - GET and PUT preference handlers with tests
status: pending status: complete
started_at: 2026-02-08T18:32:54.622410699Z
done_at: 2026-02-08T18:34:07.986803108Z
- id: task-007 - id: task-007
title: Routes, OpenAPI spec, and main.go wiring title: Routes, OpenAPI spec, and main.go wiring
status: pending status: complete
started_at: 2026-02-08T18:34:11.65603509Z
done_at: 2026-02-08T18:35:40.301958539Z
- id: task-008 - id: task-008
title: Remove example scaffold and verify clean build title: Remove example scaffold and verify clean build
status: pending status: complete
started_at: 2026-02-08T18:35:43.8274105Z
done_at: 2026-02-08T18:36:22.369967627Z

View File

@ -4,10 +4,10 @@ project:
active_work: active_work:
features: features:
- slug: user-preferences - slug: user-preferences
phase: draft phase: implementation
blocked: [] blocked: []
last_updated: 2026-02-08T18:17:02.968352206Z last_updated: 2026-02-08T18:36:22.371100629Z
last_action: CREATE_FEATURE last_action: COMPLETE_TASK
last_actor: cli last_actor: cli
history: history:
- timestamp: 2026-02-08T18:17:02.968351745Z - timestamp: 2026-02-08T18:17:02.968351745Z
@ -15,3 +15,83 @@ history:
feature: user-preferences feature: user-preferences
actor: cli actor: cli
result: success result: success
- timestamp: 2026-02-08T18:29:39.530760223Z
action: APPROVE_ARTIFACT
feature: user-preferences
actor: user
result: success
- timestamp: 2026-02-08T18:29:39.551194907Z
action: TRANSITION
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T18:29:42.703018974Z
action: APPROVE_ARTIFACT
feature: user-preferences
actor: user
result: success
- timestamp: 2026-02-08T18:29:49.408797258Z
action: APPROVE_ARTIFACT
feature: user-preferences
actor: user
result: success
- timestamp: 2026-02-08T18:29:53.147564592Z
action: APPROVE_ARTIFACT
feature: user-preferences
actor: user
result: success
- timestamp: 2026-02-08T18:29:53.154500614Z
action: TRANSITION
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T18:29:56.895691768Z
action: TRANSITION
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T18:29:56.918153176Z
action: TRANSITION
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T18:30:47.564554742Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T18:31:07.243134117Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T18:31:28.765181374Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T18:32:08.17241719Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T18:32:50.251994442Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T18:34:07.987939807Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T18:35:40.302913466Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success
- timestamp: 2026-02-08T18:36:22.371100138Z
action: COMPLETE_TASK
feature: user-preferences
actor: cli
result: success

View File

@ -2,13 +2,17 @@
package main package main
import ( import (
"database/sql"
"flag" "flag"
"fmt" "fmt"
"os" "os"
_ "github.com/lib/pq"
"git.threesix.ai/jordan/slack5-1770574304/pkg/app" "git.threesix.ai/jordan/slack5-1770574304/pkg/app"
"git.threesix.ai/jordan/slack5-1770574304/pkg/config"
"git.threesix.ai/jordan/slack5-1770574304/pkg/logging" "git.threesix.ai/jordan/slack5-1770574304/pkg/logging"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/adapter/memory" "git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/adapter/postgres"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/api" "git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/api"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/service" "git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/service"
) )
@ -33,17 +37,42 @@ func main() {
// Create logger // Create logger
logger := logging.Default() logger := logging.Default()
// Load database config
dbCfg := config.ReadDatabaseConfig()
// Open PostgreSQL connection
db, err := sql.Open("postgres", dbCfg.URL)
if err != nil {
logger.Error("failed to open database", "error", err)
os.Exit(1)
}
defer db.Close()
if dbCfg.MaxOpenConns > 0 {
db.SetMaxOpenConns(dbCfg.MaxOpenConns)
}
if dbCfg.MaxIdleConns > 0 {
db.SetMaxIdleConns(dbCfg.MaxIdleConns)
}
if dbCfg.ConnMaxLifetime > 0 {
db.SetConnMaxLifetime(dbCfg.ConnMaxLifetime)
}
// Create adapters (repositories) // Create adapters (repositories)
exampleRepo := memory.NewExampleRepository() prefRepo, err := postgres.NewPreferenceRepository(db)
if err != nil {
logger.Error("failed to create preference repository", "error", err)
os.Exit(1)
}
// Create services (business logic) // Create services (business logic)
exampleService := service.NewExampleService(exampleRepo, logger) preferenceService := service.NewPreferenceService(prefRepo, logger)
// Create application // Create application
application := app.New("preferences-api", app.WithDefaultPort(8001)) application := app.New("preferences-api", app.WithDefaultPort(8001))
// Register routes with dependency injection // Register routes with dependency injection
api.RegisterRoutes(application, exampleService) api.RegisterRoutes(application, preferenceService)
// Start server // Start server
application.Run() application.Run()

View File

@ -2,7 +2,44 @@ module git.threesix.ai/jordan/slack5-1770574304/services/preferences-api
go 1.23 go 1.23
require git.threesix.ai/jordan/slack5-1770574304/pkg v0.0.0 require (
git.threesix.ai/jordan/slack5-1770574304/pkg v0.0.0
github.com/go-chi/chi/v5 v5.2.0
github.com/lib/pq v1.10.9
)
require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/go-chi/cors v1.2.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.23.0 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.19.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
// Use local workspace modules (for Docker builds without go.work) // Use local workspace modules (for Docker builds without go.work)
replace git.threesix.ai/jordan/slack5-1770574304/pkg => ../../pkg replace git.threesix.ai/jordan/slack5-1770574304/pkg => ../../pkg

View File

@ -0,0 +1,97 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

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

View File

@ -0,0 +1,50 @@
package memory
import (
"context"
"sync"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/port"
)
// Compile-time verification that PreferenceRepository implements port.PreferenceRepository.
var _ port.PreferenceRepository = (*PreferenceRepository)(nil)
// PreferenceRepository is a thread-safe in-memory implementation of port.PreferenceRepository.
type PreferenceRepository struct {
mu sync.RWMutex
prefs map[string]*domain.UserPreferences
}
// NewPreferenceRepository creates a new in-memory preference repository.
func NewPreferenceRepository() *PreferenceRepository {
return &PreferenceRepository{
prefs: make(map[string]*domain.UserPreferences),
}
}
// Get returns the preferences for a user by ID.
// Returns nil, nil when no preferences exist.
func (r *PreferenceRepository) Get(_ context.Context, userID string) (*domain.UserPreferences, error) {
r.mu.RLock()
defer r.mu.RUnlock()
p, ok := r.prefs[userID]
if !ok {
return nil, nil
}
// Return a defensive copy
cp := *p
return &cp, nil
}
// Upsert creates or replaces preferences for a user.
func (r *PreferenceRepository) Upsert(_ context.Context, prefs *domain.UserPreferences) error {
r.mu.Lock()
defer r.mu.Unlock()
cp := *prefs
r.prefs[prefs.UserID] = &cp
return nil
}

View File

@ -0,0 +1,95 @@
package postgres
import (
"context"
"database/sql"
"fmt"
"time"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/port"
)
// Compile-time verification that PreferenceRepository implements port.PreferenceRepository.
var _ port.PreferenceRepository = (*PreferenceRepository)(nil)
// PreferenceRepository is a PostgreSQL implementation of port.PreferenceRepository.
type PreferenceRepository struct {
db *sql.DB
}
// NewPreferenceRepository creates a new PostgreSQL preference repository.
// It ensures the schema exists on creation.
func NewPreferenceRepository(db *sql.DB) (*PreferenceRepository, error) {
r := &PreferenceRepository{db: db}
if err := r.ensureSchema(); err != nil {
return nil, fmt.Errorf("ensure schema: %w", err)
}
return r, nil
}
// ensureSchema creates the user_preferences table if it does not exist.
func (r *PreferenceRepository) ensureSchema() error {
_, err := r.db.Exec(`
CREATE TABLE IF NOT EXISTS user_preferences (
user_id TEXT PRIMARY KEY,
theme TEXT NOT NULL DEFAULT 'system',
language TEXT NOT NULL DEFAULT 'en',
notify_email BOOLEAN NOT NULL DEFAULT true,
notify_push BOOLEAN NOT NULL DEFAULT true,
notify_digest TEXT NOT NULL DEFAULT 'weekly',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
return err
}
// Get returns the preferences for a user by ID.
// Returns nil, nil when no preferences exist.
func (r *PreferenceRepository) Get(ctx context.Context, userID string) (*domain.UserPreferences, error) {
var p domain.UserPreferences
var updatedAt time.Time
err := r.db.QueryRowContext(ctx,
`SELECT user_id, theme, language, notify_email, notify_push, notify_digest, updated_at
FROM user_preferences WHERE user_id = $1`, userID,
).Scan(
&p.UserID,
&p.Theme,
&p.Language,
&p.Notifications.Email,
&p.Notifications.Push,
&p.Notifications.Digest,
&updatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get preferences: %w", err)
}
p.UpdatedAt = updatedAt
return &p, nil
}
// Upsert creates or replaces preferences for a user.
func (r *PreferenceRepository) Upsert(ctx context.Context, prefs *domain.UserPreferences) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO user_preferences (user_id, theme, language, notify_email, notify_push, notify_digest, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (user_id) DO UPDATE SET
theme = $2,
language = $3,
notify_email = $4,
notify_push = $5,
notify_digest = $6,
updated_at = NOW()
`, prefs.UserID, prefs.Theme, prefs.Language,
prefs.Notifications.Email, prefs.Notifications.Push, prefs.Notifications.Digest,
)
if err != nil {
return fmt.Errorf("upsert preferences: %w", err)
}
return nil
}

View File

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

View File

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

View File

@ -0,0 +1,154 @@
package handlers
import (
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"git.threesix.ai/jordan/slack5-1770574304/pkg/app"
"git.threesix.ai/jordan/slack5-1770574304/pkg/auth"
"git.threesix.ai/jordan/slack5-1770574304/pkg/httperror"
"git.threesix.ai/jordan/slack5-1770574304/pkg/httpresponse"
"git.threesix.ai/jordan/slack5-1770574304/pkg/logging"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/service"
)
// Preference handles HTTP requests for user preference resources.
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 {
Theme string `json:"theme" validate:"required,oneof=light dark system"`
Language string `json:"language" validate:"required,oneof=en fr es de ja"`
Notifications UpdateNotificationsRequest `json:"notifications" validate:"required"`
}
// UpdateNotificationsRequest is the nested notification preferences in the request.
type UpdateNotificationsRequest struct {
Email bool `json:"email"`
Push bool `json:"push"`
Digest string `json:"digest" validate:"required,oneof=none daily weekly"`
}
// PreferencesResponse is the API response for user preferences.
type PreferencesResponse struct {
UserID string `json:"user_id"`
Theme string `json:"theme"`
Language string `json:"language"`
Notifications NotificationsResponse `json:"notifications"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// NotificationsResponse is the nested notification preferences in the response.
type NotificationsResponse struct {
Email bool `json:"email"`
Push bool `json:"push"`
Digest string `json:"digest"`
}
// toPreferencesResponse converts domain preferences to an API response.
func toPreferencesResponse(p *domain.UserPreferences) PreferencesResponse {
resp := PreferencesResponse{
UserID: p.UserID,
Theme: string(p.Theme),
Language: p.Language,
Notifications: NotificationsResponse{
Email: p.Notifications.Email,
Push: p.Notifications.Push,
Digest: string(p.Notifications.Digest),
},
}
if !p.UpdatedAt.IsZero() {
resp.UpdatedAt = p.UpdatedAt.Format(time.RFC3339)
}
return resp
}
// Get returns preferences for a user.
func (h *Preference) Get(w http.ResponseWriter, r *http.Request) error {
userID := chi.URLParam(r, "user_id")
user := auth.GetUser(r.Context())
if user == nil {
return httperror.Unauthorized("authentication required")
}
// Authorization: self-access or admin read
if user.ID != userID && !user.HasRole("admin") {
return httperror.Forbidden("access denied: cannot access another user's preferences")
}
prefs, err := h.svc.GetPreferences(r.Context(), userID)
if err != nil {
return err
}
httpresponse.OK(w, r, toPreferencesResponse(prefs))
return nil
}
// Update creates or replaces preferences for a user.
func (h *Preference) Update(w http.ResponseWriter, r *http.Request) error {
userID := chi.URLParam(r, "user_id")
user := auth.GetUser(r.Context())
if user == nil {
return httperror.Unauthorized("authentication required")
}
// Authorization: self-access only (even admins cannot write other users' preferences)
if user.ID != userID {
return httperror.Forbidden("access denied: cannot modify another user's preferences")
}
var req UpdatePreferencesRequest
if err := app.BindAndValidateStrict(r, &req); err != nil {
return err
}
prefs := &domain.UserPreferences{
Theme: domain.Theme(req.Theme),
Language: req.Language,
Notifications: domain.NotificationPreferences{
Email: req.Notifications.Email,
Push: req.Notifications.Push,
Digest: domain.DigestFrequency(req.Notifications.Digest),
},
}
result, err := h.svc.UpdatePreferences(r.Context(), userID, prefs)
if err != nil {
return mapPreferenceDomainError(err)
}
httpresponse.OK(w, r, toPreferencesResponse(result))
return nil
}
// mapPreferenceDomainError converts domain errors to HTTP errors.
func mapPreferenceDomainError(err error) error {
switch {
case errors.Is(err, domain.ErrInvalidTheme):
return httperror.BadRequest(domain.ErrInvalidTheme.Error())
case errors.Is(err, domain.ErrInvalidLanguage):
return httperror.BadRequest(domain.ErrInvalidLanguage.Error())
case errors.Is(err, domain.ErrInvalidDigest):
return httperror.BadRequest(domain.ErrInvalidDigest.Error())
default:
return err
}
}

View File

@ -0,0 +1,321 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"git.threesix.ai/jordan/slack5-1770574304/pkg/app"
"git.threesix.ai/jordan/slack5-1770574304/pkg/auth"
"git.threesix.ai/jordan/slack5-1770574304/pkg/logging"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/adapter/memory"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/service"
)
func newTestPreferenceHandler() (*Preference, *memory.PreferenceRepository) {
repo := memory.NewPreferenceRepository()
svc := service.NewPreferenceService(repo, logging.Nop())
handler := NewPreference(svc, logging.Nop())
return handler, repo
}
// withAuthUser adds an authenticated user to the request context.
func withAuthUser(r *http.Request, userID string, roles ...string) *http.Request {
user := &auth.User{
ID: userID,
Roles: roles,
}
ctx := auth.SetUser(r.Context(), user)
return r.WithContext(ctx)
}
func TestPreference_Get_SelfAccess(t *testing.T) {
handler, _ := newTestPreferenceHandler()
router := chi.NewRouter()
router.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Get))
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/user-1", nil)
req = withAuthUser(req, "user-1")
w := httptest.NewRecorder()
router.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
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
data, ok := resp["data"].(map[string]any)
if !ok {
t.Fatal("expected 'data' field in response")
}
// Should return defaults
if data["theme"] != "system" {
t.Errorf("expected default theme system, got %v", data["theme"])
}
if data["language"] != "en" {
t.Errorf("expected default language en, got %v", data["language"])
}
if data["user_id"] != "user-1" {
t.Errorf("expected user_id user-1, got %v", data["user_id"])
}
// Defaults should not have updated_at
if _, exists := data["updated_at"]; exists && data["updated_at"] != "" {
// updated_at may be present but empty for defaults
}
}
func TestPreference_Get_AdminAccess(t *testing.T) {
handler, _ := newTestPreferenceHandler()
router := chi.NewRouter()
router.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Get))
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/user-1", nil)
req = withAuthUser(req, "admin-user", "admin")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200 for admin, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestPreference_Get_Forbidden(t *testing.T) {
handler, _ := newTestPreferenceHandler()
router := chi.NewRouter()
router.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Get))
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/user-1", nil)
req = withAuthUser(req, "other-user")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected status 403, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestPreference_Update_SelfAccess(t *testing.T) {
handler, _ := newTestPreferenceHandler()
router := chi.NewRouter()
router.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Update))
body := UpdatePreferencesRequest{
Theme: "dark",
Language: "fr",
Notifications: UpdateNotificationsRequest{
Email: true,
Push: false,
Digest: "daily",
},
}
bodyBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/user-1", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req = withAuthUser(req, "user-1")
w := httptest.NewRecorder()
router.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
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
data, ok := resp["data"].(map[string]any)
if !ok {
t.Fatal("expected 'data' field in response")
}
if data["theme"] != "dark" {
t.Errorf("expected theme dark, got %v", data["theme"])
}
if data["language"] != "fr" {
t.Errorf("expected language fr, got %v", data["language"])
}
notifications, ok := data["notifications"].(map[string]any)
if !ok {
t.Fatal("expected 'notifications' nested object")
}
if notifications["push"] != false {
t.Errorf("expected notifications.push false, got %v", notifications["push"])
}
if notifications["digest"] != "daily" {
t.Errorf("expected notifications.digest daily, got %v", notifications["digest"])
}
}
func TestPreference_Update_Forbidden(t *testing.T) {
handler, _ := newTestPreferenceHandler()
router := chi.NewRouter()
router.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Update))
body := UpdatePreferencesRequest{
Theme: "dark",
Language: "en",
Notifications: UpdateNotificationsRequest{
Email: true,
Push: true,
Digest: "weekly",
},
}
bodyBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/user-1", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req = withAuthUser(req, "other-user")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected status 403, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestPreference_Update_AdminForbidden(t *testing.T) {
handler, _ := newTestPreferenceHandler()
router := chi.NewRouter()
router.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Update))
body := UpdatePreferencesRequest{
Theme: "dark",
Language: "en",
Notifications: UpdateNotificationsRequest{
Email: true,
Push: true,
Digest: "weekly",
},
}
bodyBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/user-1", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req = withAuthUser(req, "admin-user", "admin")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Even admins cannot modify another user's preferences
if w.Code != http.StatusForbidden {
t.Errorf("expected status 403 for admin write, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestPreference_Update_InvalidBody(t *testing.T) {
handler, _ := newTestPreferenceHandler()
router := chi.NewRouter()
router.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Update))
// Invalid: missing required fields
bodyBytes := []byte(`{"theme": "dark"}`)
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/user-1", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req = withAuthUser(req, "user-1")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for invalid body, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestPreference_Update_UnknownFields(t *testing.T) {
handler, _ := newTestPreferenceHandler()
router := chi.NewRouter()
router.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Update))
bodyBytes := []byte(`{"theme":"dark","language":"en","notifications":{"email":true,"push":true,"digest":"weekly"},"unknown_field":"value"}`)
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/user-1", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req = withAuthUser(req, "user-1")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for unknown fields, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestPreference_Update_InvalidThemeValue(t *testing.T) {
handler, _ := newTestPreferenceHandler()
router := chi.NewRouter()
router.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Update))
bodyBytes := []byte(`{"theme":"invalid","language":"en","notifications":{"email":true,"push":true,"digest":"weekly"}}`)
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/user-1", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req = withAuthUser(req, "user-1")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for invalid theme, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestPreference_Get_ExistingPreferences(t *testing.T) {
handler, repo := newTestPreferenceHandler()
// Seed existing preferences
existing := &domain.UserPreferences{
UserID: "user-1",
Theme: domain.ThemeDark,
Language: "ja",
Notifications: domain.NotificationPreferences{
Email: false,
Push: false,
Digest: domain.DigestNone,
},
}
_ = repo.Upsert(nil, existing)
router := chi.NewRouter()
router.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Get))
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/user-1", nil)
req = withAuthUser(req, "user-1")
w := httptest.NewRecorder()
router.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
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
data := resp["data"].(map[string]any)
if data["theme"] != "dark" {
t.Errorf("expected theme dark, got %v", data["theme"])
}
if data["language"] != "ja" {
t.Errorf("expected language ja, got %v", data["language"])
}
}

View File

@ -13,14 +13,14 @@ import (
// Routes are mounted under /api/preferences-api to match the ingress path routing. // Routes are mounted under /api/preferences-api to match the ingress path routing.
// This allows the monorepo to expose multiple services under a single domain: // This allows the monorepo to expose multiple services under a single domain:
// - https://domain/api/preferences-api/health // - https://domain/api/preferences-api/health
// - https://domain/api/preferences-api/examples // - https://domain/api/preferences-api/preferences/{user_id}
func RegisterRoutes(application *app.App, exampleService *service.ExampleService) { func RegisterRoutes(application *app.App, preferenceService *service.PreferenceService) {
logger := application.Logger() logger := application.Logger()
cfg := config.Load() cfg := config.Load()
// Initialize handlers with injected services // Initialize handlers with injected services
healthHandler := handlers.NewHealth(logger) healthHandler := handlers.NewHealth(logger)
exampleHandler := handlers.NewExample(exampleService, logger) preferenceHandler := handlers.NewPreference(preferenceService, logger)
// Build and mount OpenAPI spec // Build and mount OpenAPI spec
spec := NewServiceSpec() spec := NewServiceSpec()
@ -31,10 +31,6 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
application.Route("/api/preferences-api", func(r app.Router) { application.Route("/api/preferences-api", func(r app.Router) {
r.Get("/health", healthHandler.Check) r.Get("/health", healthHandler.Check)
// Public routes (no auth required)
r.Get("/examples", app.Wrap(exampleHandler.List))
r.Get("/examples/{id}", app.Wrap(exampleHandler.Get))
// Protected routes (auth required when enabled) // Protected routes (auth required when enabled)
r.Group(func(r app.Router) { r.Group(func(r app.Router) {
if cfg.AuthEnabled { if cfg.AuthEnabled {
@ -46,9 +42,8 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
})) }))
} }
r.Post("/examples", app.Wrap(exampleHandler.Create)) r.Get("/preferences/{user_id}", app.Wrap(preferenceHandler.Get))
r.Put("/examples/{id}", app.Wrap(exampleHandler.Update)) r.Put("/preferences/{user_id}", app.Wrap(preferenceHandler.Update))
r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete))
}) })
}) })
} }

View File

@ -5,29 +5,31 @@ import "git.threesix.ai/jordan/slack5-1770574304/pkg/openapi"
// NewServiceSpec builds the OpenAPI specification for the preferences-api service. // NewServiceSpec builds the OpenAPI specification for the preferences-api service.
func NewServiceSpec() *openapi.OpenAPISpec { func NewServiceSpec() *openapi.OpenAPISpec {
spec := openapi.NewOpenAPISpec("preferences-api API", "1.0.0"). spec := openapi.NewOpenAPISpec("preferences-api API", "1.0.0").
WithDescription("REST API for the preferences-api service"). WithDescription("REST API for user preferences management").
WithBearerSecurity("bearer", "JWT authentication token"). WithBearerSecurity("bearer", "JWT authentication token").
WithTag("Health", "Service health endpoints"). WithTag("Health", "Service health endpoints").
WithTag("Examples", "Example CRUD endpoints") WithTag("Preferences", "User preference endpoints")
// Define reusable schemas // Define reusable schemas
spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{ spec.WithSchema("NotificationPreferences", openapi.Object(map[string]openapi.Schema{
"id": openapi.UUID().WithDescription("Unique identifier"), "email": openapi.Bool().WithDescription("Email notifications enabled"),
"name": openapi.String().WithDescription("Name of the example").WithExample("My Example"), "push": openapi.Bool().WithDescription("Push notifications enabled"),
"description": openapi.String().WithDescription("Optional description").WithExample("A description"), "digest": openapi.StringEnum("none", "daily", "weekly").WithDescription("Digest frequency"),
"created_at": openapi.DateTime().WithDescription("Creation timestamp"), }, "email", "push", "digest"))
"updated_at": openapi.DateTime().WithDescription("Last update timestamp"),
}, "id", "name"))
spec.WithSchema("CreateExampleRequest", openapi.Object(map[string]openapi.Schema{ spec.WithSchema("UserPreferences", openapi.Object(map[string]openapi.Schema{
"name": openapi.StringWithMinMax(1, 100).WithDescription("Name of the example"), "user_id": openapi.String().WithDescription("User identifier"),
"description": openapi.StringWithMinMax(0, 500).WithDescription("Optional description"), "theme": openapi.StringEnum("light", "dark", "system").WithDescription("UI theme"),
}, "name")) "language": openapi.StringEnum("en", "fr", "es", "de", "ja").WithDescription("Language (BCP-47)"),
"notifications": openapi.Ref("NotificationPreferences"),
"updated_at": openapi.DateTime().WithDescription("Last update timestamp"),
}, "user_id", "theme", "language", "notifications"))
spec.WithSchema("UpdateExampleRequest", openapi.Object(map[string]openapi.Schema{ spec.WithSchema("UpdatePreferencesRequest", openapi.Object(map[string]openapi.Schema{
"name": openapi.StringWithMinMax(1, 100).WithDescription("Updated name"), "theme": openapi.StringEnum("light", "dark", "system").WithDescription("UI theme"),
"description": openapi.StringWithMinMax(0, 500).WithDescription("Updated description"), "language": openapi.StringEnum("en", "fr", "es", "de", "ja").WithDescription("Language (BCP-47)"),
})) "notifications": openapi.Ref("NotificationPreferences"),
}, "theme", "language", "notifications"))
// Health // Health
spec.AddPath("/api/preferences-api/health", "get", map[string]any{ spec.AddPath("/api/preferences-api/health", "get", map[string]any{
@ -41,70 +43,35 @@ func NewServiceSpec() *openapi.OpenAPISpec {
}, },
}) })
// List examples userIDParam := openapi.PathParam("user_id", "User identifier")
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 // Get preferences
spec.AddPath("/api/preferences-api/examples/{id}", "get", map[string]any{ spec.AddPath("/api/preferences-api/preferences/{user_id}", "get", map[string]any{
"summary": "Get example by ID", "summary": "Get user preferences",
"tags": []string{"Examples"}, "description": "Returns preferences for the specified user. Returns defaults if none are saved. Admins may read any user's preferences.",
"parameters": []any{openapi.IDParam()}, "tags": []string{"Preferences"},
"responses": map[string]any{
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("Example"))),
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
},
})
// Create example
spec.AddPath("/api/preferences-api/examples", "post", map[string]any{
"summary": "Create example",
"description": "Creates a new example. Requires authentication.",
"tags": []string{"Examples"},
"security": []map[string][]string{{"bearer": {}}}, "security": []map[string][]string{{"bearer": {}}},
"requestBody": openapi.RequestBody(openapi.Ref("CreateExampleRequest"), true), "parameters": []any{userIDParam},
"responses": map[string]any{ "responses": map[string]any{
"201": openapi.OpResponse("Created", openapi.ResponseSchema(openapi.Ref("Example"))), "200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("UserPreferences"))),
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
"403": openapi.OpResponse("Forbidden", openapi.ErrorResponseSchema()),
},
})
// Update preferences
spec.AddPath("/api/preferences-api/preferences/{user_id}", "put", map[string]any{
"summary": "Update user preferences",
"description": "Creates or fully replaces preferences for the specified user (upsert). Self-access only.",
"tags": []string{"Preferences"},
"security": []map[string][]string{{"bearer": {}}},
"parameters": []any{userIDParam},
"requestBody": openapi.RequestBody(openapi.Ref("UpdatePreferencesRequest"), true),
"responses": map[string]any{
"200": openapi.OpResponse("Updated", openapi.ResponseSchema(openapi.Ref("UserPreferences"))),
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()), "400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()), "401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
"422": openapi.OpResponse("Validation error", openapi.ErrorResponseSchema()), "403": openapi.OpResponse("Forbidden", openapi.ErrorResponseSchema()),
},
})
// Update example
spec.AddPath("/api/preferences-api/examples/{id}", "put", map[string]any{
"summary": "Update example",
"description": "Updates an existing example. Requires authentication.",
"tags": []string{"Examples"},
"security": []map[string][]string{{"bearer": {}}},
"parameters": []any{openapi.IDParam()},
"requestBody": openapi.RequestBody(openapi.Ref("UpdateExampleRequest"), true),
"responses": map[string]any{
"200": openapi.OpResponse("Updated", openapi.ResponseSchema(openapi.Ref("Example"))),
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
},
})
// Delete example
spec.AddPath("/api/preferences-api/examples/{id}", "delete", map[string]any{
"summary": "Delete example",
"description": "Deletes an example by ID. Requires authentication.",
"tags": []string{"Examples"},
"security": []map[string][]string{{"bearer": {}}},
"parameters": []any{openapi.IDParam()},
"responses": map[string]any{
"204": openapi.OpResponseNoContent(),
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
}, },
}) })

View File

@ -7,15 +7,12 @@ import "errors"
// Domain errors - these are business-level errors that should be translated // Domain errors - these are business-level errors that should be translated
// to appropriate HTTP status codes by the handler layer. // to appropriate HTTP status codes by the handler layer.
var ( var (
// ErrNotFound indicates a requested resource does not exist. // ErrInvalidTheme indicates an invalid theme value was provided.
ErrNotFound = errors.New("not found") ErrInvalidTheme = errors.New("theme must be one of: light, dark, system")
// ErrExampleNotFound indicates the requested example does not exist. // ErrInvalidLanguage indicates an invalid language value was provided.
ErrExampleNotFound = errors.New("example not found") ErrInvalidLanguage = errors.New("language must be one of: en, fr, es, de, ja")
// ErrDuplicateExample indicates an example with the same name already exists. // ErrInvalidDigest indicates an invalid digest frequency was provided.
ErrDuplicateExample = errors.New("example with this name already exists") ErrInvalidDigest = errors.New("notifications.digest must be one of: none, daily, weekly")
// ErrInvalidExampleName indicates the example name is invalid.
ErrInvalidExampleName = errors.New("invalid example name")
) )

View File

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

View File

@ -0,0 +1,89 @@
package domain
import "time"
// Theme represents the UI theme preference.
type Theme string
const (
ThemeLight Theme = "light"
ThemeDark Theme = "dark"
ThemeSystem Theme = "system"
)
// validThemes is the set of allowed theme values.
var validThemes = map[Theme]bool{
ThemeLight: true,
ThemeDark: true,
ThemeSystem: true,
}
// DigestFrequency represents the email digest frequency preference.
type DigestFrequency string
const (
DigestNone DigestFrequency = "none"
DigestDaily DigestFrequency = "daily"
DigestWeekly DigestFrequency = "weekly"
)
// validDigests is the set of allowed digest frequency values.
var validDigests = map[DigestFrequency]bool{
DigestNone: true,
DigestDaily: true,
DigestWeekly: true,
}
// validLanguages is the set of allowed BCP-47 language tags.
var validLanguages = map[string]bool{
"en": true,
"fr": true,
"es": true,
"de": true,
"ja": true,
}
// NotificationPreferences holds notification-related preferences.
type NotificationPreferences struct {
Email bool
Push bool
Digest DigestFrequency
}
// UserPreferences represents a user's preference settings.
type UserPreferences struct {
UserID string
Theme Theme
Language string
Notifications NotificationPreferences
UpdatedAt time.Time
}
// DefaultPreferences returns the default preferences for a given user ID.
func DefaultPreferences(userID string) *UserPreferences {
return &UserPreferences{
UserID: userID,
Theme: ThemeSystem,
Language: "en",
Notifications: NotificationPreferences{
Email: true,
Push: true,
Digest: DigestWeekly,
},
}
}
// Validate checks that all preference values are valid.
// Returns a domain error if any value is invalid.
func (p *UserPreferences) Validate() error {
if !validThemes[p.Theme] {
return ErrInvalidTheme
}
if !validLanguages[p.Language] {
return ErrInvalidLanguage
}
if !validDigests[p.Notifications.Digest] {
return ErrInvalidDigest
}
return nil
}

View File

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

View File

@ -0,0 +1,18 @@
package port
import (
"context"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/domain"
)
// PreferenceRepository defines the interface for user preference persistence.
// Implementations may use databases, in-memory storage, or external services.
type PreferenceRepository interface {
// Get returns the preferences for a user by ID.
// Returns nil, nil when no preferences exist (service applies defaults).
Get(ctx context.Context, userID string) (*domain.UserPreferences, error)
// Upsert creates or replaces the preferences for a user.
Upsert(ctx context.Context, prefs *domain.UserPreferences) error
}

View File

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

View File

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

View File

@ -0,0 +1,56 @@
package service
import (
"context"
"time"
"git.threesix.ai/jordan/slack5-1770574304/pkg/logging"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/port"
)
// PreferenceService handles preference-related 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 the preferences for a user.
// Returns default preferences if none are stored.
func (s *PreferenceService) GetPreferences(ctx context.Context, userID string) (*domain.UserPreferences, error) {
prefs, err := s.repo.Get(ctx, userID)
if err != nil {
return nil, err
}
if prefs == nil {
return domain.DefaultPreferences(userID), nil
}
return prefs, nil
}
// UpdatePreferences validates and persists user preferences.
func (s *PreferenceService) UpdatePreferences(ctx context.Context, userID string, prefs *domain.UserPreferences) (*domain.UserPreferences, error) {
prefs.UserID = userID
prefs.UpdatedAt = time.Now().UTC()
if err := prefs.Validate(); err != nil {
return nil, err
}
if err := s.repo.Upsert(ctx, prefs); err != nil {
return nil, err
}
s.logger.Info("preferences updated", "user_id", userID)
// Re-fetch to get the DB-set updated_at timestamp
return s.repo.Get(ctx, userID)
}

View File

@ -0,0 +1,174 @@
package service
import (
"context"
"testing"
"git.threesix.ai/jordan/slack5-1770574304/pkg/logging"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/adapter/memory"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/domain"
)
func newTestPreferenceService() (*PreferenceService, *memory.PreferenceRepository) {
repo := memory.NewPreferenceRepository()
svc := NewPreferenceService(repo, logging.Nop())
return svc, repo
}
func TestPreferenceService_GetPreferences_Defaults(t *testing.T) {
svc, _ := newTestPreferenceService()
prefs, err := svc.GetPreferences(context.Background(), "user-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if prefs.UserID != "user-1" {
t.Errorf("expected user_id user-1, got %s", prefs.UserID)
}
if prefs.Theme != domain.ThemeSystem {
t.Errorf("expected default theme system, got %s", prefs.Theme)
}
if prefs.Language != "en" {
t.Errorf("expected default language en, got %s", prefs.Language)
}
if !prefs.Notifications.Email {
t.Error("expected default notifications.email true")
}
if !prefs.Notifications.Push {
t.Error("expected default notifications.push true")
}
if prefs.Notifications.Digest != domain.DigestWeekly {
t.Errorf("expected default digest weekly, got %s", prefs.Notifications.Digest)
}
}
func TestPreferenceService_GetPreferences_Existing(t *testing.T) {
svc, repo := newTestPreferenceService()
// Seed existing preferences
existing := &domain.UserPreferences{
UserID: "user-1",
Theme: domain.ThemeDark,
Language: "fr",
Notifications: domain.NotificationPreferences{
Email: false,
Push: true,
Digest: domain.DigestDaily,
},
}
_ = repo.Upsert(context.Background(), existing)
prefs, err := svc.GetPreferences(context.Background(), "user-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if prefs.Theme != domain.ThemeDark {
t.Errorf("expected theme dark, got %s", prefs.Theme)
}
if prefs.Language != "fr" {
t.Errorf("expected language fr, got %s", prefs.Language)
}
if prefs.Notifications.Email {
t.Error("expected notifications.email false")
}
if prefs.Notifications.Digest != domain.DigestDaily {
t.Errorf("expected digest daily, got %s", prefs.Notifications.Digest)
}
}
func TestPreferenceService_UpdatePreferences_Valid(t *testing.T) {
svc, _ := newTestPreferenceService()
prefs := &domain.UserPreferences{
Theme: domain.ThemeDark,
Language: "ja",
Notifications: domain.NotificationPreferences{
Email: false,
Push: false,
Digest: domain.DigestNone,
},
}
result, err := svc.UpdatePreferences(context.Background(), "user-1", prefs)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.UserID != "user-1" {
t.Errorf("expected user_id user-1, got %s", result.UserID)
}
if result.Theme != domain.ThemeDark {
t.Errorf("expected theme dark, got %s", result.Theme)
}
if result.Language != "ja" {
t.Errorf("expected language ja, got %s", result.Language)
}
}
func TestPreferenceService_UpdatePreferences_InvalidTheme(t *testing.T) {
svc, _ := newTestPreferenceService()
prefs := &domain.UserPreferences{
Theme: "invalid",
Language: "en",
Notifications: domain.NotificationPreferences{
Email: true,
Push: true,
Digest: domain.DigestWeekly,
},
}
_, err := svc.UpdatePreferences(context.Background(), "user-1", prefs)
if err == nil {
t.Fatal("expected error for invalid theme")
}
if err != domain.ErrInvalidTheme {
t.Errorf("expected ErrInvalidTheme, got %v", err)
}
}
func TestPreferenceService_UpdatePreferences_InvalidLanguage(t *testing.T) {
svc, _ := newTestPreferenceService()
prefs := &domain.UserPreferences{
Theme: domain.ThemeLight,
Language: "xx",
Notifications: domain.NotificationPreferences{
Email: true,
Push: true,
Digest: domain.DigestWeekly,
},
}
_, err := svc.UpdatePreferences(context.Background(), "user-1", prefs)
if err == nil {
t.Fatal("expected error for invalid language")
}
if err != domain.ErrInvalidLanguage {
t.Errorf("expected ErrInvalidLanguage, got %v", err)
}
}
func TestPreferenceService_UpdatePreferences_InvalidDigest(t *testing.T) {
svc, _ := newTestPreferenceService()
prefs := &domain.UserPreferences{
Theme: domain.ThemeLight,
Language: "en",
Notifications: domain.NotificationPreferences{
Email: true,
Push: true,
Digest: "biweekly",
},
}
_, err := svc.UpdatePreferences(context.Background(), "user-1", prefs)
if err == nil {
t.Fatal("expected error for invalid digest")
}
if err != domain.ErrInvalidDigest {
t.Errorf("expected ErrInvalidDigest, got %v", err)
}
}