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
title: User Preferences API
created: 2026-02-08T18:17:02.968042724Z
phase: draft
phase: implementation
phase_history:
- phase: draft
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:
audit:
status: pending
path: audit.md
design:
status: draft
status: approved
path: design.md
approved_by: user
approved_at: 2026-02-08T18:29:42.702450956Z
qa_plan:
status: draft
status: approved
path: qa-plan.md
approved_by: user
approved_at: 2026-02-08T18:29:53.146901385Z
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-08T18:29:39.530157029Z
tasks:
status: draft
status: approved
path: tasks.md
approved_by: user
approved_at: 2026-02-08T18:29:49.407986092Z
total: 8
completed: 8
tasks:
- id: task-001
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
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
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
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
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
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
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
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:
features:
- slug: user-preferences
phase: draft
phase: implementation
blocked: []
last_updated: 2026-02-08T18:17:02.968352206Z
last_action: CREATE_FEATURE
last_updated: 2026-02-08T18:36:22.371100629Z
last_action: COMPLETE_TASK
last_actor: cli
history:
- timestamp: 2026-02-08T18:17:02.968351745Z
@ -15,3 +15,83 @@ history:
feature: user-preferences
actor: cli
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
import (
"database/sql"
"flag"
"fmt"
"os"
_ "github.com/lib/pq"
"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/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/service"
)
@ -33,17 +37,42 @@ func main() {
// Create logger
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)
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)
exampleService := service.NewExampleService(exampleRepo, logger)
preferenceService := service.NewPreferenceService(prefRepo, logger)
// Create application
application := app.New("preferences-api", app.WithDefaultPort(8001))
// Register routes with dependency injection
api.RegisterRoutes(application, exampleService)
api.RegisterRoutes(application, preferenceService)
// Start server
application.Run()

View File

@ -2,7 +2,44 @@ module git.threesix.ai/jordan/slack5-1770574304/services/preferences-api
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)
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.
// 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) {
// - https://domain/api/preferences-api/preferences/{user_id}
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)
preferenceHandler := handlers.NewPreference(preferenceService, logger)
// Build and mount OpenAPI spec
spec := NewServiceSpec()
@ -31,10 +31,6 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
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)
r.Group(func(r app.Router) {
if cfg.AuthEnabled {
@ -46,9 +42,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(preferenceHandler.Get))
r.Put("/preferences/{user_id}", app.Wrap(preferenceHandler.Update))
})
})
}

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.
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 user preferences management").
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("NotificationPreferences", openapi.Object(map[string]openapi.Schema{
"email": openapi.Bool().WithDescription("Email notifications enabled"),
"push": openapi.Bool().WithDescription("Push notifications enabled"),
"digest": openapi.StringEnum("none", "daily", "weekly").WithDescription("Digest frequency"),
}, "email", "push", "digest"))
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("UserPreferences", openapi.Object(map[string]openapi.Schema{
"user_id": openapi.String().WithDescription("User identifier"),
"theme": openapi.StringEnum("light", "dark", "system").WithDescription("UI theme"),
"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{
"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{
"theme": openapi.StringEnum("light", "dark", "system").WithDescription("UI theme"),
"language": openapi.StringEnum("en", "fr", "es", "de", "ja").WithDescription("Language (BCP-47)"),
"notifications": openapi.Ref("NotificationPreferences"),
}, "theme", "language", "notifications"))
// Health
spec.AddPath("/api/preferences-api/health", "get", map[string]any{
@ -41,70 +43,35 @@ 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"))),
},
})
userIDParam := openapi.PathParam("user_id", "User identifier")
// 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 preferences for the specified user. Returns defaults if none are saved. Admins may read any user's preferences.",
"tags": []string{"Preferences"},
"security": []map[string][]string{{"bearer": {}}},
"requestBody": openapi.RequestBody(openapi.Ref("CreateExampleRequest"), true),
"parameters": []any{userIDParam},
"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()),
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
"422": openapi.OpResponse("Validation error", 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()),
"403": openapi.OpResponse("Forbidden", openapi.ErrorResponseSchema()),
},
})

View File

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

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