Compare commits
1 Commits
main
...
feature/us
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
868f79c67a |
4
.sdlc/branches/feature/user-preferences.yaml
Normal file
4
.sdlc/branches/feature/user-preferences.yaml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
name: feature/user-preferences
|
||||||
|
feature: user-preferences
|
||||||
|
base_branch: main
|
||||||
|
created_at: 2026-02-07T23:39:16.105121631Z
|
||||||
@ -1,20 +1,37 @@
|
|||||||
slug: user-preferences
|
slug: user-preferences
|
||||||
title: User Preferences API
|
title: User Preferences API
|
||||||
created: 2026-02-07T23:12:01.063203551Z
|
created: 2026-02-07T23:12:01.063203551Z
|
||||||
phase: draft
|
branch: feature/user-preferences
|
||||||
|
phase: implementation
|
||||||
phase_history:
|
phase_history:
|
||||||
- phase: draft
|
- phase: draft
|
||||||
entered: 2026-02-07T23:12:01.063203551Z
|
entered: 2026-02-07T23:12:01.063203551Z
|
||||||
|
exited: 2026-02-07T23:39:07.87389147Z
|
||||||
|
- phase: specified
|
||||||
|
entered: 2026-02-07T23:39:07.87389147Z
|
||||||
|
exited: 2026-02-07T23:39:11.359990304Z
|
||||||
|
- phase: planned
|
||||||
|
entered: 2026-02-07T23:39:11.359990304Z
|
||||||
|
exited: 2026-02-07T23:39:19.198461276Z
|
||||||
|
- phase: ready
|
||||||
|
entered: 2026-02-07T23:39:19.198461276Z
|
||||||
|
exited: 2026-02-07T23:39:19.203777581Z
|
||||||
|
- phase: implementation
|
||||||
|
entered: 2026-02-07T23:39:19.203777581Z
|
||||||
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-07T23:38:40.749288466Z
|
||||||
qa_plan:
|
qa_plan:
|
||||||
status: draft
|
status: approved
|
||||||
path: qa-plan.md
|
path: qa-plan.md
|
||||||
|
approved_by: user
|
||||||
|
approved_at: 2026-02-07T23:38:40.758636348Z
|
||||||
qa_results:
|
qa_results:
|
||||||
status: pending
|
status: pending
|
||||||
path: qa-results.md
|
path: qa-results.md
|
||||||
@ -22,28 +39,45 @@ 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-07T23:38:40.742142758Z
|
||||||
tasks:
|
tasks:
|
||||||
status: draft
|
status: approved
|
||||||
path: tasks.md
|
path: tasks.md
|
||||||
|
approved_by: user
|
||||||
|
approved_at: 2026-02-07T23:38:40.753950891Z
|
||||||
total: 6
|
total: 6
|
||||||
|
completed: 6
|
||||||
tasks:
|
tasks:
|
||||||
- id: task-001
|
- id: task-001
|
||||||
title: Remove example scaffold code
|
title: Remove example scaffold code
|
||||||
status: pending
|
status: complete
|
||||||
|
started_at: 2026-02-07T23:41:32.212851131Z
|
||||||
|
done_at: 2026-02-07T23:42:26.740580949Z
|
||||||
- id: task-002
|
- id: task-002
|
||||||
title: Implement domain layer - preference types, validation, and errors
|
title: Implement domain layer - preference types, validation, and errors
|
||||||
status: pending
|
status: complete
|
||||||
|
started_at: 2026-02-07T23:42:33.623557389Z
|
||||||
|
done_at: 2026-02-07T23:43:11.947818976Z
|
||||||
- id: task-003
|
- id: task-003
|
||||||
title: Implement port interface and PostgreSQL adapter with migration
|
title: Implement port interface and PostgreSQL adapter with migration
|
||||||
status: pending
|
status: complete
|
||||||
|
started_at: 2026-02-07T23:43:18.173154992Z
|
||||||
|
done_at: 2026-02-07T23:43:52.078776138Z
|
||||||
- id: task-004
|
- id: task-004
|
||||||
title: Implement service layer with business logic and tests
|
title: Implement service layer with business logic and tests
|
||||||
status: pending
|
status: complete
|
||||||
|
started_at: 2026-02-07T23:43:57.594557872Z
|
||||||
|
done_at: 2026-02-07T23:44:36.019869806Z
|
||||||
- id: task-005
|
- id: task-005
|
||||||
title: Implement HTTP handlers with auth ownership check and tests
|
title: Implement HTTP handlers with auth ownership check and tests
|
||||||
status: pending
|
status: complete
|
||||||
|
started_at: 2026-02-07T23:44:42.180586004Z
|
||||||
|
done_at: 2026-02-07T23:45:41.334873217Z
|
||||||
- id: task-006
|
- id: task-006
|
||||||
title: Wire routes, OpenAPI spec, and main.go integration
|
title: Wire routes, OpenAPI spec, and main.go integration
|
||||||
status: pending
|
status: complete
|
||||||
|
started_at: 2026-02-07T23:45:47.679034139Z
|
||||||
|
done_at: 2026-02-07T23:47:02.818236182Z
|
||||||
|
|||||||
@ -4,10 +4,11 @@ project:
|
|||||||
active_work:
|
active_work:
|
||||||
features:
|
features:
|
||||||
- slug: user-preferences
|
- slug: user-preferences
|
||||||
phase: draft
|
branch: feature/user-preferences
|
||||||
|
phase: implementation
|
||||||
blocked: []
|
blocked: []
|
||||||
last_updated: 2026-02-07T23:12:01.063732867Z
|
last_updated: 2026-02-07T23:47:02.819096901Z
|
||||||
last_action: CREATE_FEATURE
|
last_action: COMPLETE_TASK
|
||||||
last_actor: cli
|
last_actor: cli
|
||||||
history:
|
history:
|
||||||
- timestamp: 2026-02-07T23:12:01.063732416Z
|
- timestamp: 2026-02-07T23:12:01.063732416Z
|
||||||
@ -15,3 +16,78 @@ history:
|
|||||||
feature: user-preferences
|
feature: user-preferences
|
||||||
actor: cli
|
actor: cli
|
||||||
result: success
|
result: success
|
||||||
|
- timestamp: 2026-02-07T23:38:40.742694587Z
|
||||||
|
action: APPROVE_ARTIFACT
|
||||||
|
feature: user-preferences
|
||||||
|
actor: user
|
||||||
|
result: success
|
||||||
|
- timestamp: 2026-02-07T23:38:40.749789319Z
|
||||||
|
action: APPROVE_ARTIFACT
|
||||||
|
feature: user-preferences
|
||||||
|
actor: user
|
||||||
|
result: success
|
||||||
|
- timestamp: 2026-02-07T23:38:40.754437947Z
|
||||||
|
action: APPROVE_ARTIFACT
|
||||||
|
feature: user-preferences
|
||||||
|
actor: user
|
||||||
|
result: success
|
||||||
|
- timestamp: 2026-02-07T23:38:40.759149714Z
|
||||||
|
action: APPROVE_ARTIFACT
|
||||||
|
feature: user-preferences
|
||||||
|
actor: user
|
||||||
|
result: success
|
||||||
|
- timestamp: 2026-02-07T23:39:07.874483213Z
|
||||||
|
action: TRANSITION
|
||||||
|
feature: user-preferences
|
||||||
|
actor: cli
|
||||||
|
result: success
|
||||||
|
- timestamp: 2026-02-07T23:39:11.360594661Z
|
||||||
|
action: TRANSITION
|
||||||
|
feature: user-preferences
|
||||||
|
actor: cli
|
||||||
|
result: success
|
||||||
|
- timestamp: 2026-02-07T23:39:16.111511757Z
|
||||||
|
action: CREATE_BRANCH
|
||||||
|
feature: user-preferences
|
||||||
|
actor: cli
|
||||||
|
result: success
|
||||||
|
- timestamp: 2026-02-07T23:39:19.19930268Z
|
||||||
|
action: TRANSITION
|
||||||
|
feature: user-preferences
|
||||||
|
actor: cli
|
||||||
|
result: success
|
||||||
|
- timestamp: 2026-02-07T23:39:19.214739329Z
|
||||||
|
action: TRANSITION
|
||||||
|
feature: user-preferences
|
||||||
|
actor: cli
|
||||||
|
result: success
|
||||||
|
- timestamp: 2026-02-07T23:42:26.741311174Z
|
||||||
|
action: COMPLETE_TASK
|
||||||
|
feature: user-preferences
|
||||||
|
actor: cli
|
||||||
|
result: success
|
||||||
|
- timestamp: 2026-02-07T23:43:11.948520326Z
|
||||||
|
action: COMPLETE_TASK
|
||||||
|
feature: user-preferences
|
||||||
|
actor: cli
|
||||||
|
result: success
|
||||||
|
- timestamp: 2026-02-07T23:43:52.079542581Z
|
||||||
|
action: COMPLETE_TASK
|
||||||
|
feature: user-preferences
|
||||||
|
actor: cli
|
||||||
|
result: success
|
||||||
|
- timestamp: 2026-02-07T23:44:36.02062654Z
|
||||||
|
action: COMPLETE_TASK
|
||||||
|
feature: user-preferences
|
||||||
|
actor: cli
|
||||||
|
result: success
|
||||||
|
- timestamp: 2026-02-07T23:45:41.335807033Z
|
||||||
|
action: COMPLETE_TASK
|
||||||
|
feature: user-preferences
|
||||||
|
actor: cli
|
||||||
|
result: success
|
||||||
|
- timestamp: 2026-02-07T23:47:02.819096009Z
|
||||||
|
action: COMPLETE_TASK
|
||||||
|
feature: user-preferences
|
||||||
|
actor: cli
|
||||||
|
result: success
|
||||||
|
|||||||
@ -2,28 +2,50 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/pkg/app"
|
"git.threesix.ai/jordan/slate-test-1770505673/pkg/app"
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/pkg/database"
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/pkg/logging"
|
"git.threesix.ai/jordan/slate-test-1770505673/pkg/logging"
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/adapter/memory"
|
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/adapter/postgres"
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/api"
|
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/api"
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/config"
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/service"
|
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Create logger
|
|
||||||
logger := logging.Default()
|
logger := logging.Default()
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
// Create adapters (repositories)
|
// Connect to database
|
||||||
exampleRepo := memory.NewExampleRepository()
|
pool := database.MustConnect(context.Background(), cfg.Database.URL, database.Options{
|
||||||
|
MaxOpenConns: cfg.Database.MaxOpenConns,
|
||||||
|
MaxIdleConns: cfg.Database.MaxIdleConns,
|
||||||
|
})
|
||||||
|
|
||||||
// Create services (business logic)
|
// Run migrations
|
||||||
exampleService := service.NewExampleService(exampleRepo, logger)
|
database.MustRunMigrations(context.Background(), pool, migrationsFS, "migrations")
|
||||||
|
|
||||||
|
// Create adapters
|
||||||
|
prefRepo := postgres.NewPreferenceRepository(pool.DB, logger)
|
||||||
|
|
||||||
|
// Create services
|
||||||
|
prefService := 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
|
// Close DB pool on shutdown
|
||||||
api.RegisterRoutes(application, exampleService)
|
application.OnShutdown(func(ctx context.Context) error {
|
||||||
|
return pool.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
api.RegisterRoutes(application, prefService)
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
application.Run()
|
application.Run()
|
||||||
|
|||||||
@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
key VARCHAR(64) NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_preferences_user_id ON user_preferences (user_id);
|
||||||
@ -2,7 +2,46 @@ module git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api
|
|||||||
|
|
||||||
go 1.23
|
go 1.23
|
||||||
|
|
||||||
require git.threesix.ai/jordan/slate-test-1770505673/pkg v0.0.0
|
require (
|
||||||
|
git.threesix.ai/jordan/slate-test-1770505673/pkg v0.0.0
|
||||||
|
github.com/go-chi/chi/v5 v5.2.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bdpiprava/scalar-go v0.13.0 // indirect
|
||||||
|
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/hashicorp/hcl v1.0.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/lib/pq v1.10.9 // 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/slate-test-1770505673/pkg => ../../pkg
|
replace git.threesix.ai/jordan/slate-test-1770505673/pkg => ../../pkg
|
||||||
|
|||||||
@ -0,0 +1,108 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/bdpiprava/scalar-go v0.13.0 h1:TuhOwYalDpLAziohyEwZlq4PqtEJ+6P/V92dDCdja9k=
|
||||||
|
github.com/bdpiprava/scalar-go v0.13.0/go.mod h1:e5Nn4yIhcYjlucu4ACMqcs410nIAe5whqj78H3Qv7vw=
|
||||||
|
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/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
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/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||||
|
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||||
|
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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
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/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.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=
|
||||||
@ -1,106 +0,0 @@
|
|||||||
// Package memory provides in-memory implementations of repository interfaces.
|
|
||||||
// Useful for development, testing, and prototyping.
|
|
||||||
package memory
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/domain"
|
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Compile-time verification that ExampleRepository implements port.ExampleRepository.
|
|
||||||
var _ port.ExampleRepository = (*ExampleRepository)(nil)
|
|
||||||
|
|
||||||
// ExampleRepository is a thread-safe in-memory implementation of port.ExampleRepository.
|
|
||||||
type ExampleRepository struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
examples map[domain.ExampleID]*domain.Example
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewExampleRepository creates a new in-memory example repository.
|
|
||||||
func NewExampleRepository() *ExampleRepository {
|
|
||||||
return &ExampleRepository{
|
|
||||||
examples: make(map[domain.ExampleID]*domain.Example),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns all examples.
|
|
||||||
func (r *ExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
|
|
||||||
result := make([]domain.Example, 0, len(r.examples))
|
|
||||||
for _, e := range r.examples {
|
|
||||||
result = append(result, *e)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns an example by ID.
|
|
||||||
// Returns domain.ErrExampleNotFound if not found.
|
|
||||||
func (r *ExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
|
|
||||||
e, ok := r.examples[id]
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrExampleNotFound
|
|
||||||
}
|
|
||||||
// Return a copy to prevent external mutation
|
|
||||||
copy := *e
|
|
||||||
return ©, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create stores a new example.
|
|
||||||
func (r *ExampleRepository) Create(ctx context.Context, example *domain.Example) error {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
|
|
||||||
// Store a copy to prevent external mutation
|
|
||||||
copy := *example
|
|
||||||
r.examples[example.ID] = ©
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update modifies an existing example.
|
|
||||||
// Returns domain.ErrExampleNotFound if not found.
|
|
||||||
func (r *ExampleRepository) Update(ctx context.Context, example *domain.Example) error {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
|
|
||||||
if _, ok := r.examples[example.ID]; !ok {
|
|
||||||
return domain.ErrExampleNotFound
|
|
||||||
}
|
|
||||||
// Store a copy to prevent external mutation
|
|
||||||
copy := *example
|
|
||||||
r.examples[example.ID] = ©
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes an example by ID.
|
|
||||||
// Returns domain.ErrExampleNotFound if not found.
|
|
||||||
func (r *ExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
|
|
||||||
if _, ok := r.examples[id]; !ok {
|
|
||||||
return domain.ErrExampleNotFound
|
|
||||||
}
|
|
||||||
delete(r.examples, id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExistsByName checks if an example with the given name exists.
|
|
||||||
func (r *ExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
|
|
||||||
for _, e := range r.examples {
|
|
||||||
if e.Name == name {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
// Package postgres provides PostgreSQL implementations of repository interfaces.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/pkg/logging"
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/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 *sqlx.DB
|
||||||
|
logger *logging.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPreferenceRepository creates a new PostgreSQL preference repository.
|
||||||
|
func NewPreferenceRepository(db *sqlx.DB, logger *logging.Logger) *PreferenceRepository {
|
||||||
|
return &PreferenceRepository{
|
||||||
|
db: db,
|
||||||
|
logger: logger.WithComponent("PreferenceRepository"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// preferenceRow represents a row in the user_preferences table.
|
||||||
|
type preferenceRow struct {
|
||||||
|
Key string `db:"key"`
|
||||||
|
Value string `db:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByUserID returns all preferences for a user as a map[key]value.
|
||||||
|
// Returns an empty map (not nil) if the user has no preferences.
|
||||||
|
func (r *PreferenceRepository) GetByUserID(ctx context.Context, userID string) (map[string]string, error) {
|
||||||
|
var rows []preferenceRow
|
||||||
|
err := r.db.SelectContext(ctx, &rows,
|
||||||
|
`SELECT key, value FROM user_preferences WHERE user_id = $1`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying preferences: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]string, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
result[row.Key] = row.Value
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert creates or updates preferences for a user within a single transaction.
|
||||||
|
// Only the provided keys are affected; existing keys not in the map are preserved.
|
||||||
|
func (r *PreferenceRepository) Upsert(ctx context.Context, userID string, prefs map[string]string) error {
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("beginning transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
stmt, err := tx.PrepareContext(ctx, `
|
||||||
|
INSERT INTO user_preferences (user_id, key, value)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, key)
|
||||||
|
DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("preparing statement: %w", err)
|
||||||
|
}
|
||||||
|
defer closeStmt(stmt)
|
||||||
|
|
||||||
|
for key, value := range prefs {
|
||||||
|
if _, err := stmt.ExecContext(ctx, userID, key, value); err != nil {
|
||||||
|
return fmt.Errorf("upserting preference %s: %w", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("committing transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeStmt(stmt *sql.Stmt) {
|
||||||
|
_ = stmt.Close()
|
||||||
|
}
|
||||||
@ -1,170 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/pkg/app"
|
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/pkg/httperror"
|
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/pkg/httpresponse"
|
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/pkg/logging"
|
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/domain"
|
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Example handles HTTP requests for example resources.
|
|
||||||
type Example struct {
|
|
||||||
svc *service.ExampleService
|
|
||||||
logger *logging.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewExample creates a new Example handler with injected dependencies.
|
|
||||||
func NewExample(svc *service.ExampleService, logger *logging.Logger) *Example {
|
|
||||||
return &Example{
|
|
||||||
svc: svc,
|
|
||||||
logger: logger.WithComponent("ExampleHandler"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateRequest is the request body for creating an example.
|
|
||||||
type CreateRequest struct {
|
|
||||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
|
||||||
Description string `json:"description" validate:"max=500"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateRequest is the request body for updating an example.
|
|
||||||
type UpdateRequest struct {
|
|
||||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
|
||||||
Description string `json:"description" validate:"max=500"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExampleResponse is the response for an example resource.
|
|
||||||
type ExampleResponse struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// toResponse converts a domain example to an API response.
|
|
||||||
func toResponse(e *domain.Example) ExampleResponse {
|
|
||||||
return ExampleResponse{
|
|
||||||
ID: e.ID.String(),
|
|
||||||
Name: e.Name,
|
|
||||||
Description: e.Description,
|
|
||||||
CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
||||||
UpdatedAt: e.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns all examples.
|
|
||||||
func (h *Example) List(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
examples, err := h.svc.List(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make([]ExampleResponse, len(examples))
|
|
||||||
for i, e := range examples {
|
|
||||||
result[i] = toResponse(&e)
|
|
||||||
}
|
|
||||||
|
|
||||||
httpresponse.OK(w, r, result)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns an example by ID.
|
|
||||||
func (h *Example) Get(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
// Validate UUID format
|
|
||||||
if _, err := uuid.Parse(id); err != nil {
|
|
||||||
return httperror.BadRequest("invalid id format")
|
|
||||||
}
|
|
||||||
|
|
||||||
example, err := h.svc.Get(r.Context(), domain.ExampleID(id))
|
|
||||||
if err != nil {
|
|
||||||
return mapDomainError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
httpresponse.OK(w, r, toResponse(example))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create creates a new example.
|
|
||||||
func (h *Example) Create(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
var req CreateRequest
|
|
||||||
if err := app.BindAndValidate(r, &req); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
example, err := h.svc.Create(r.Context(), service.CreateInput{
|
|
||||||
Name: req.Name,
|
|
||||||
Description: req.Description,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return mapDomainError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
httpresponse.Created(w, r, toResponse(example))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update updates an existing example.
|
|
||||||
func (h *Example) Update(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
if _, err := uuid.Parse(id); err != nil {
|
|
||||||
return httperror.BadRequest("invalid id format")
|
|
||||||
}
|
|
||||||
|
|
||||||
var req UpdateRequest
|
|
||||||
if err := app.BindAndValidate(r, &req); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
example, err := h.svc.Update(r.Context(), domain.ExampleID(id), service.UpdateInput{
|
|
||||||
Name: req.Name,
|
|
||||||
Description: req.Description,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return mapDomainError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
httpresponse.OK(w, r, toResponse(example))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes an example by ID.
|
|
||||||
func (h *Example) Delete(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
if _, err := uuid.Parse(id); err != nil {
|
|
||||||
return httperror.BadRequest("invalid id format")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.svc.Delete(r.Context(), domain.ExampleID(id)); err != nil {
|
|
||||||
return mapDomainError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
httpresponse.NoContent(w)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// mapDomainError converts domain errors to HTTP errors.
|
|
||||||
func mapDomainError(err error) error {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, domain.ErrExampleNotFound):
|
|
||||||
return httperror.NotFound("example not found")
|
|
||||||
case errors.Is(err, domain.ErrDuplicateExample):
|
|
||||||
return httperror.Conflict("example with this name already exists")
|
|
||||||
case errors.Is(err, domain.ErrInvalidExampleName):
|
|
||||||
return httperror.BadRequest("invalid example name")
|
|
||||||
default:
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,402 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/pkg/logging"
|
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/domain"
|
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/port"
|
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mockExampleRepository implements port.ExampleRepository for testing.
|
|
||||||
type mockExampleRepository struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
examples map[domain.ExampleID]*domain.Example
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.ExampleRepository = (*mockExampleRepository)(nil)
|
|
||||||
|
|
||||||
func newMockExampleRepository() *mockExampleRepository {
|
|
||||||
return &mockExampleRepository{
|
|
||||||
examples: make(map[domain.ExampleID]*domain.Example),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
result := make([]domain.Example, 0, len(m.examples))
|
|
||||||
for _, e := range m.examples {
|
|
||||||
result = append(result, *e)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
e, ok := m.examples[id]
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrExampleNotFound
|
|
||||||
}
|
|
||||||
copy := *e
|
|
||||||
return ©, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
copy := *example
|
|
||||||
m.examples[example.ID] = ©
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
if _, ok := m.examples[example.ID]; !ok {
|
|
||||||
return domain.ErrExampleNotFound
|
|
||||||
}
|
|
||||||
copy := *example
|
|
||||||
m.examples[example.ID] = ©
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
if _, ok := m.examples[id]; !ok {
|
|
||||||
return domain.ErrExampleNotFound
|
|
||||||
}
|
|
||||||
delete(m.examples, id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
for _, e := range m.examples {
|
|
||||||
if e.Name == name {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestHandler() (*Example, *mockExampleRepository) {
|
|
||||||
repo := newMockExampleRepository()
|
|
||||||
svc := service.NewExampleService(repo, logging.Nop())
|
|
||||||
handler := NewExample(svc, logging.Nop())
|
|
||||||
return handler, repo
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExample_List(t *testing.T) {
|
|
||||||
handler, repo := newTestHandler()
|
|
||||||
|
|
||||||
// Seed data
|
|
||||||
ex, _ := domain.NewExample("test-id-1", "Test Example", "Description")
|
|
||||||
_ = repo.Create(context.Background(), ex)
|
|
||||||
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Get("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := handler.List(w, r); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/examples", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Errorf("expected status 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp map[string]any
|
|
||||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
||||||
t.Fatalf("failed to decode response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, ok := resp["data"]
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected 'data' field in response")
|
|
||||||
}
|
|
||||||
|
|
||||||
items, ok := data.([]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected 'data' to be an array")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(items) != 1 {
|
|
||||||
t.Errorf("expected 1 item, got %d", len(items))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExample_Get(t *testing.T) {
|
|
||||||
handler, repo := newTestHandler()
|
|
||||||
|
|
||||||
// Seed data
|
|
||||||
ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Test Example", "Description")
|
|
||||||
_ = repo.Create(context.Background(), ex)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
id string
|
|
||||||
wantStatus int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid uuid - found",
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
wantStatus: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid uuid - not found",
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440001",
|
|
||||||
wantStatus: http.StatusNotFound,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid uuid",
|
|
||||||
id: "not-a-uuid",
|
|
||||||
wantStatus: http.StatusBadRequest,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Get("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := handler.Get(w, r); err != nil {
|
|
||||||
// Map error to status for testing
|
|
||||||
switch tt.wantStatus {
|
|
||||||
case http.StatusNotFound:
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
case http.StatusBadRequest:
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
default:
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/examples/"+tt.id, nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != tt.wantStatus {
|
|
||||||
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExample_Create(t *testing.T) {
|
|
||||||
handler, repo := newTestHandler()
|
|
||||||
|
|
||||||
// Seed existing data for duplicate test
|
|
||||||
ex, _ := domain.NewExample("existing-id", "Existing Name", "")
|
|
||||||
_ = repo.Create(context.Background(), ex)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
body any
|
|
||||||
wantStatus int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid request",
|
|
||||||
body: CreateRequest{
|
|
||||||
Name: "New Example",
|
|
||||||
Description: "A test description",
|
|
||||||
},
|
|
||||||
wantStatus: http.StatusCreated,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty body",
|
|
||||||
body: nil,
|
|
||||||
wantStatus: http.StatusBadRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "duplicate name",
|
|
||||||
body: CreateRequest{
|
|
||||||
Name: "Existing Name",
|
|
||||||
Description: "Conflict",
|
|
||||||
},
|
|
||||||
wantStatus: http.StatusConflict,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Post("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := handler.Create(w, r); err != nil {
|
|
||||||
switch tt.wantStatus {
|
|
||||||
case http.StatusBadRequest:
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
case http.StatusConflict:
|
|
||||||
w.WriteHeader(http.StatusConflict)
|
|
||||||
default:
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
var body []byte
|
|
||||||
if tt.body != nil {
|
|
||||||
var err error
|
|
||||||
body, err = json.Marshal(tt.body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to marshal body: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/examples", bytes.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != tt.wantStatus {
|
|
||||||
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExample_Delete(t *testing.T) {
|
|
||||||
handler, repo := newTestHandler()
|
|
||||||
|
|
||||||
// Seed data
|
|
||||||
ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "To Delete", "")
|
|
||||||
_ = repo.Create(context.Background(), ex)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
id string
|
|
||||||
wantStatus int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "existing example",
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
wantStatus: http.StatusNoContent,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-existent example",
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440001",
|
|
||||||
wantStatus: http.StatusNotFound,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Delete("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := handler.Delete(w, r); err != nil {
|
|
||||||
if tt.wantStatus == http.StatusNotFound {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
} else {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/examples/"+tt.id, nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != tt.wantStatus {
|
|
||||||
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExample_Update(t *testing.T) {
|
|
||||||
handler, repo := newTestHandler()
|
|
||||||
|
|
||||||
// Seed data
|
|
||||||
ex1, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Example 1", "")
|
|
||||||
_ = repo.Create(context.Background(), ex1)
|
|
||||||
ex2, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440001", "Example 2", "")
|
|
||||||
_ = repo.Create(context.Background(), ex2)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
id string
|
|
||||||
body UpdateRequest
|
|
||||||
wantStatus int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid update",
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
body: UpdateRequest{
|
|
||||||
Name: "Updated Name",
|
|
||||||
Description: "Updated",
|
|
||||||
},
|
|
||||||
wantStatus: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "name conflict",
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
body: UpdateRequest{
|
|
||||||
Name: "Example 2",
|
|
||||||
Description: "Conflict",
|
|
||||||
},
|
|
||||||
wantStatus: http.StatusConflict,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "not found",
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440099",
|
|
||||||
body: UpdateRequest{
|
|
||||||
Name: "Whatever",
|
|
||||||
Description: "",
|
|
||||||
},
|
|
||||||
wantStatus: http.StatusNotFound,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Put("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := handler.Update(w, r); err != nil {
|
|
||||||
switch tt.wantStatus {
|
|
||||||
case http.StatusNotFound:
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
case http.StatusConflict:
|
|
||||||
w.WriteHeader(http.StatusConflict)
|
|
||||||
default:
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
body, _ := json.Marshal(tt.body)
|
|
||||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/examples/"+tt.id, bytes.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != tt.wantStatus {
|
|
||||||
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
108
services/preferences-api/internal/api/handlers/preference.go
Normal file
108
services/preferences-api/internal/api/handlers/preference.go
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/pkg/app"
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/pkg/auth"
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/pkg/httperror"
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/pkg/httpresponse"
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/pkg/logging"
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/domain"
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns all preferences for a user.
|
||||||
|
func (h *Preference) Get(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
userID := chi.URLParam(r, "user_id")
|
||||||
|
|
||||||
|
if _, err := uuid.Parse(userID); err != nil {
|
||||||
|
return httperror.BadRequest("invalid user ID format")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.checkOwnership(r, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs, err := h.svc.Get(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
httpresponse.OK(w, r, prefs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update creates or updates preferences for a user.
|
||||||
|
func (h *Preference) Update(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
userID := chi.URLParam(r, "user_id")
|
||||||
|
|
||||||
|
if _, err := uuid.Parse(userID); err != nil {
|
||||||
|
return httperror.BadRequest("invalid user ID format")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.checkOwnership(r, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var prefs map[string]string
|
||||||
|
if err := app.Bind(r, &prefs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(prefs) == 0 {
|
||||||
|
return httperror.BadRequest("request body is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.svc.Upsert(r.Context(), userID, prefs)
|
||||||
|
if err != nil {
|
||||||
|
return mapDomainError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpresponse.OK(w, r, result)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkOwnership verifies that the authenticated user matches the requested user_id.
|
||||||
|
func (h *Preference) checkOwnership(r *http.Request, userID string) error {
|
||||||
|
user := auth.GetUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
return httperror.Unauthorized("authentication required")
|
||||||
|
}
|
||||||
|
if user.ID != userID {
|
||||||
|
return httperror.Forbidden("cannot access preferences for another user")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapDomainError converts domain errors to HTTP errors.
|
||||||
|
func mapDomainError(err error) error {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, domain.ErrUnknownKey):
|
||||||
|
return httperror.BadRequest(err.Error())
|
||||||
|
case errors.Is(err, domain.ErrInvalidValue):
|
||||||
|
return httperror.BadRequest(err.Error())
|
||||||
|
case errors.Is(err, domain.ErrForbidden):
|
||||||
|
return httperror.Forbidden(err.Error())
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,243 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/pkg/app"
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/pkg/auth"
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/pkg/logging"
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/port"
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testUserID = "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
const otherUserID = "550e8400-e29b-41d4-a716-446655440001"
|
||||||
|
|
||||||
|
// mockPreferenceRepository implements port.PreferenceRepository for testing.
|
||||||
|
type mockPreferenceRepository struct {
|
||||||
|
data map[string]map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ port.PreferenceRepository = (*mockPreferenceRepository)(nil)
|
||||||
|
|
||||||
|
func newMockRepo() *mockPreferenceRepository {
|
||||||
|
return &mockPreferenceRepository{
|
||||||
|
data: make(map[string]map[string]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPreferenceRepository) GetByUserID(_ context.Context, userID string) (map[string]string, error) {
|
||||||
|
prefs, ok := m.data[userID]
|
||||||
|
if !ok {
|
||||||
|
return make(map[string]string), nil
|
||||||
|
}
|
||||||
|
result := make(map[string]string, len(prefs))
|
||||||
|
for k, v := range prefs {
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPreferenceRepository) Upsert(_ context.Context, userID string, prefs map[string]string) error {
|
||||||
|
if m.data[userID] == nil {
|
||||||
|
m.data[userID] = make(map[string]string)
|
||||||
|
}
|
||||||
|
for k, v := range prefs {
|
||||||
|
m.data[userID][k] = v
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestHandler() (*Preference, *mockPreferenceRepository) {
|
||||||
|
repo := newMockRepo()
|
||||||
|
svc := service.NewPreferenceService(repo, logging.Nop())
|
||||||
|
handler := NewPreference(svc, logging.Nop())
|
||||||
|
return handler, repo
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupRouter creates a test router with the handler wrapped in app.Wrap.
|
||||||
|
func setupRouter(handler *Preference) *chi.Mux {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Get))
|
||||||
|
r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Update))
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// withAuth adds an authenticated user to the request context.
|
||||||
|
func withAuth(r *http.Request, userID string) *http.Request {
|
||||||
|
ctx := auth.SetUser(r.Context(), &auth.User{ID: userID})
|
||||||
|
return r.WithContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreference_Get(t *testing.T) {
|
||||||
|
handler, repo := newTestHandler()
|
||||||
|
router := setupRouter(handler)
|
||||||
|
|
||||||
|
t.Run("returns empty prefs for user with no preferences", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/"+testUserID, nil)
|
||||||
|
req = withAuth(req, testUserID)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
data, ok := resp["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected 'data' to be an object")
|
||||||
|
}
|
||||||
|
if len(data) != 0 {
|
||||||
|
t.Errorf("expected empty data, got %v", data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns preferences for user with data", func(t *testing.T) {
|
||||||
|
repo.data[testUserID] = map[string]string{"theme": "dark", "language": "en"}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/"+testUserID, nil)
|
||||||
|
req = withAuth(req, testUserID)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
data := resp["data"].(map[string]any)
|
||||||
|
if data["theme"] != "dark" {
|
||||||
|
t.Errorf("expected theme 'dark', got '%v'", data["theme"])
|
||||||
|
}
|
||||||
|
if data["language"] != "en" {
|
||||||
|
t.Errorf("expected language 'en', got '%v'", data["language"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
delete(repo.data, testUserID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns 400 for invalid UUID", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/not-a-uuid", nil)
|
||||||
|
req = withAuth(req, testUserID)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns 403 for accessing another user's preferences", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/"+otherUserID, nil)
|
||||||
|
req = withAuth(req, testUserID)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected status 403, got %d", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreference_Update(t *testing.T) {
|
||||||
|
handler, _ := newTestHandler()
|
||||||
|
router := setupRouter(handler)
|
||||||
|
|
||||||
|
t.Run("updates preferences successfully", func(t *testing.T) {
|
||||||
|
body, _ := json.Marshal(map[string]string{"theme": "dark", "language": "fr"})
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+testUserID, bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withAuth(req, testUserID)
|
||||||
|
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
|
||||||
|
json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
data := resp["data"].(map[string]any)
|
||||||
|
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"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns 400 for unknown key", func(t *testing.T) {
|
||||||
|
body, _ := json.Marshal(map[string]string{"unknown_key": "val"})
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+testUserID, bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withAuth(req, testUserID)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d; body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns 400 for invalid value", func(t *testing.T) {
|
||||||
|
body, _ := json.Marshal(map[string]string{"theme": "blue"})
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+testUserID, bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withAuth(req, testUserID)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d; body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns 400 for empty body", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+testUserID, nil)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withAuth(req, testUserID)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d; body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns 400 for invalid UUID", func(t *testing.T) {
|
||||||
|
body, _ := json.Marshal(map[string]string{"theme": "dark"})
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/not-a-uuid", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withAuth(req, testUserID)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns 403 for accessing another user's preferences", func(t *testing.T) {
|
||||||
|
body, _ := json.Marshal(map[string]string{"theme": "dark"})
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+otherUserID, bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withAuth(req, testUserID)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected status 403, got %d", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -10,45 +10,33 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// RegisterRoutes registers all HTTP routes for the service.
|
// RegisterRoutes registers all HTTP routes for the service.
|
||||||
// Routes are mounted under /api/preferences-api to match the ingress path routing.
|
func RegisterRoutes(application *app.App, prefService *service.PreferenceService) {
|
||||||
// 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) {
|
|
||||||
logger := application.Logger()
|
logger := application.Logger()
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
// Initialize handlers with injected services
|
// Initialize handlers
|
||||||
healthHandler := handlers.NewHealth(logger)
|
healthHandler := handlers.NewHealth(logger)
|
||||||
exampleHandler := handlers.NewExample(exampleService, logger)
|
prefHandler := handlers.NewPreference(prefService, logger)
|
||||||
|
|
||||||
// Build and mount OpenAPI spec
|
// Build and mount OpenAPI spec
|
||||||
spec := NewServiceSpec()
|
spec := NewServiceSpec()
|
||||||
application.EnableDocs(spec)
|
application.EnableDocs(spec)
|
||||||
|
|
||||||
// Register API routes under /api/{service-name} to match ingress path routing.
|
|
||||||
// The ingress routes /api/preferences-api/* to this service.
|
|
||||||
application.Route("/api/preferences-api", func(r app.Router) {
|
application.Route("/api/preferences-api", func(r app.Router) {
|
||||||
|
// Health endpoint (no auth)
|
||||||
r.Get("/health", healthHandler.Check)
|
r.Get("/health", healthHandler.Check)
|
||||||
|
|
||||||
// Public routes (no auth required)
|
// Preference endpoints (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) {
|
r.Group(func(r app.Router) {
|
||||||
if cfg.AuthEnabled {
|
r.Use(auth.Middleware(auth.MiddlewareConfig{
|
||||||
r.Use(auth.Middleware(auth.MiddlewareConfig{
|
Validator: auth.NewJWTValidator(auth.JWTConfig{
|
||||||
Validator: auth.NewJWTValidator(auth.JWTConfig{
|
Secret: []byte(cfg.JWTSecret),
|
||||||
Secret: []byte(cfg.JWTSecret),
|
Issuer: "slate-test-1770505673",
|
||||||
Issuer: "slate-test-1770505673",
|
}),
|
||||||
}),
|
}))
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
r.Post("/examples", app.Wrap(exampleHandler.Create))
|
r.Get("/preferences/{user_id}", app.Wrap(prefHandler.Get))
|
||||||
r.Put("/examples/{id}", app.Wrap(exampleHandler.Update))
|
r.Put("/preferences/{user_id}", app.Wrap(prefHandler.Update))
|
||||||
r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete))
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,30 +5,26 @@ import "git.threesix.ai/jordan/slate-test-1770505673/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
|
// Reusable schemas
|
||||||
spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{
|
spec.WithSchema("PreferencesMap", openapi.Object(map[string]openapi.Schema{
|
||||||
"id": openapi.UUID().WithDescription("Unique identifier"),
|
"theme": openapi.StringEnum("light", "dark", "system").WithDescription("UI theme preference"),
|
||||||
"name": openapi.String().WithDescription("Name of the example").WithExample("My Example"),
|
"language": openapi.String().WithPattern("^[a-z]{2}$").WithDescription("ISO 639-1 language code"),
|
||||||
"description": openapi.String().WithDescription("Optional description").WithExample("A description"),
|
"notifications_enabled": openapi.StringEnum("true", "false").WithDescription("Notification toggle"),
|
||||||
"created_at": openapi.DateTime().WithDescription("Creation timestamp"),
|
|
||||||
"updated_at": openapi.DateTime().WithDescription("Last update timestamp"),
|
|
||||||
}, "id", "name"))
|
|
||||||
|
|
||||||
spec.WithSchema("CreateExampleRequest", openapi.Object(map[string]openapi.Schema{
|
|
||||||
"name": openapi.StringWithMinMax(1, 100).WithDescription("Name of the example"),
|
|
||||||
"description": openapi.StringWithMinMax(0, 500).WithDescription("Optional description"),
|
|
||||||
}, "name"))
|
|
||||||
|
|
||||||
spec.WithSchema("UpdateExampleRequest", openapi.Object(map[string]openapi.Schema{
|
|
||||||
"name": openapi.StringWithMinMax(1, 100).WithDescription("Updated name"),
|
|
||||||
"description": openapi.StringWithMinMax(0, 500).WithDescription("Updated description"),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
spec.WithSchema("UpdatePreferencesRequest", openapi.Object(map[string]openapi.Schema{
|
||||||
|
"theme": openapi.StringEnum("light", "dark", "system").WithDescription("UI theme preference"),
|
||||||
|
"language": openapi.String().WithPattern("^[a-z]{2}$").WithDescription("ISO 639-1 language code"),
|
||||||
|
"notifications_enabled": openapi.StringEnum("true", "false").WithDescription("Notification toggle"),
|
||||||
|
}))
|
||||||
|
|
||||||
|
userIDParam := openapi.PathParamWithSchema("user_id", "User UUID", openapi.UUID())
|
||||||
|
|
||||||
// Health
|
// Health
|
||||||
spec.AddPath("/api/preferences-api/health", "get", map[string]any{
|
spec.AddPath("/api/preferences-api/health", "get", map[string]any{
|
||||||
"summary": "Health check",
|
"summary": "Health check",
|
||||||
@ -41,70 +37,34 @@ func NewServiceSpec() *openapi.OpenAPISpec {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// List examples
|
// GET /preferences/{user_id}
|
||||||
spec.AddPath("/api/preferences-api/examples", "get", map[string]any{
|
spec.AddPath("/api/preferences-api/preferences/{user_id}", "get", map[string]any{
|
||||||
"summary": "List examples",
|
"summary": "Get user preferences",
|
||||||
"description": "Returns a paginated list of examples.",
|
"description": "Returns all preferences for a user. Returns empty object if no preferences are set.",
|
||||||
"tags": []string{"Examples"},
|
"tags": []string{"Preferences"},
|
||||||
"parameters": []any{openapi.PageParam(), openapi.PerPageParam()},
|
|
||||||
"responses": map[string]any{
|
|
||||||
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.RefArray("Example"))),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Get example
|
|
||||||
spec.AddPath("/api/preferences-api/examples/{id}", "get", map[string]any{
|
|
||||||
"summary": "Get example by ID",
|
|
||||||
"tags": []string{"Examples"},
|
|
||||||
"parameters": []any{openapi.IDParam()},
|
|
||||||
"responses": map[string]any{
|
|
||||||
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("Example"))),
|
|
||||||
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create example
|
|
||||||
spec.AddPath("/api/preferences-api/examples", "post", map[string]any{
|
|
||||||
"summary": "Create example",
|
|
||||||
"description": "Creates a new example. Requires authentication.",
|
|
||||||
"tags": []string{"Examples"},
|
|
||||||
"security": []map[string][]string{{"bearer": {}}},
|
"security": []map[string][]string{{"bearer": {}}},
|
||||||
"requestBody": openapi.RequestBody(openapi.Ref("CreateExampleRequest"), true),
|
"parameters": []any{userIDParam},
|
||||||
"responses": map[string]any{
|
"responses": map[string]any{
|
||||||
"201": openapi.OpResponse("Created", openapi.ResponseSchema(openapi.Ref("Example"))),
|
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("PreferencesMap"))),
|
||||||
|
"400": openapi.OpResponse("Invalid user ID", openapi.ErrorResponseSchema()),
|
||||||
|
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
|
||||||
|
"403": openapi.OpResponse("Forbidden", openapi.ErrorResponseSchema()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// PUT /preferences/{user_id}
|
||||||
|
spec.AddPath("/api/preferences-api/preferences/{user_id}", "put", map[string]any{
|
||||||
|
"summary": "Update user preferences",
|
||||||
|
"description": "Creates or updates preferences for a user (partial upsert). Returns the full preference set after the update.",
|
||||||
|
"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("PreferencesMap"))),
|
||||||
"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()),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,21 +1,15 @@
|
|||||||
// Package domain contains pure domain models with no external dependencies.
|
// Package domain contains pure domain models with no external dependencies.
|
||||||
// These types represent the core business concepts of the service.
|
|
||||||
package domain
|
package domain
|
||||||
|
|
||||||
import "errors"
|
import "errors"
|
||||||
|
|
||||||
// Domain errors - these are business-level errors that should be translated
|
|
||||||
// to appropriate HTTP status codes by the handler layer.
|
|
||||||
var (
|
var (
|
||||||
// ErrNotFound indicates a requested resource does not exist.
|
// ErrUnknownKey indicates a preference key is not in the allowed set.
|
||||||
ErrNotFound = errors.New("not found")
|
ErrUnknownKey = errors.New("unknown preference key")
|
||||||
|
|
||||||
// ErrExampleNotFound indicates the requested example does not exist.
|
// ErrInvalidValue indicates a preference value is not valid for the given key.
|
||||||
ErrExampleNotFound = errors.New("example not found")
|
ErrInvalidValue = errors.New("invalid preference value")
|
||||||
|
|
||||||
// ErrDuplicateExample indicates an example with the same name already exists.
|
// ErrForbidden indicates the user is not allowed to access the resource.
|
||||||
ErrDuplicateExample = errors.New("example with this name already exists")
|
ErrForbidden = errors.New("access denied")
|
||||||
|
|
||||||
// ErrInvalidExampleName indicates the example name is invalid.
|
|
||||||
ErrInvalidExampleName = errors.New("invalid example name")
|
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,89 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
"unicode/utf8"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ExampleID is a strongly-typed identifier for examples.
|
|
||||||
type ExampleID string
|
|
||||||
|
|
||||||
// String returns the string representation of the ID.
|
|
||||||
func (id ExampleID) String() string {
|
|
||||||
return string(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsZero returns true if the ID is empty.
|
|
||||||
func (id ExampleID) IsZero() bool {
|
|
||||||
return id == ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example name constraints.
|
|
||||||
const (
|
|
||||||
MinExampleNameLen = 1
|
|
||||||
MaxExampleNameLen = 100
|
|
||||||
MaxDescriptionLen = 500
|
|
||||||
)
|
|
||||||
|
|
||||||
// Example represents an example domain entity.
|
|
||||||
// This is a pure domain model with no external dependencies.
|
|
||||||
type Example struct {
|
|
||||||
ID ExampleID
|
|
||||||
Name string
|
|
||||||
Description string
|
|
||||||
CreatedAt time.Time
|
|
||||||
UpdatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewExample creates a new Example with validation.
|
|
||||||
// Returns ErrInvalidExampleName if the name is invalid.
|
|
||||||
func NewExample(id ExampleID, name, description string) (*Example, error) {
|
|
||||||
if err := validateExampleName(name); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := validateDescription(description); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
return &Example{
|
|
||||||
ID: id,
|
|
||||||
Name: name,
|
|
||||||
Description: description,
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update modifies the example's mutable fields with validation.
|
|
||||||
// Returns ErrInvalidExampleName if the name is invalid.
|
|
||||||
func (e *Example) Update(name, description string) error {
|
|
||||||
if err := validateExampleName(name); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := validateDescription(description); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
e.Name = name
|
|
||||||
e.Description = description
|
|
||||||
e.UpdatedAt = time.Now().UTC()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateExampleName validates an example name.
|
|
||||||
func validateExampleName(name string) error {
|
|
||||||
length := utf8.RuneCountInString(name)
|
|
||||||
if length < MinExampleNameLen || length > MaxExampleNameLen {
|
|
||||||
return ErrInvalidExampleName
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateDescription validates a description.
|
|
||||||
func validateDescription(desc string) error {
|
|
||||||
if utf8.RuneCountInString(desc) > MaxDescriptionLen {
|
|
||||||
return ErrInvalidExampleName
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
66
services/preferences-api/internal/domain/preference.go
Normal file
66
services/preferences-api/internal/domain/preference.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// languageRegex validates ISO 639-1 language codes (two lowercase letters).
|
||||||
|
var languageRegex = regexp.MustCompile(`^[a-z]{2}$`)
|
||||||
|
|
||||||
|
// AllowedKeys defines the valid preference keys and their allowed values.
|
||||||
|
// An empty slice means the value is validated by a custom rule (e.g., regex).
|
||||||
|
var AllowedKeys = map[string][]string{
|
||||||
|
"theme": {"light", "dark", "system"},
|
||||||
|
"language": {}, // validated via languageRegex
|
||||||
|
"notifications_enabled": {"true", "false"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preference represents a single user preference key-value pair.
|
||||||
|
type Preference struct {
|
||||||
|
UserID string
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks that Key is known and Value is valid for that key.
|
||||||
|
func (p *Preference) Validate() error {
|
||||||
|
if err := ValidateKey(p.Key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ValidateValue(p.Key, p.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateKey checks if a key is in the allowed set.
|
||||||
|
func ValidateKey(key string) error {
|
||||||
|
if _, ok := AllowedKeys[key]; !ok {
|
||||||
|
return fmt.Errorf("%w: %s", ErrUnknownKey, key)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateValue checks if a value is valid for the given key.
|
||||||
|
func ValidateValue(key, value string) error {
|
||||||
|
allowed, ok := AllowedKeys[key]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%w: %s", ErrUnknownKey, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Language uses regex validation
|
||||||
|
if key == "language" {
|
||||||
|
if !languageRegex.MatchString(value) {
|
||||||
|
return fmt.Errorf("%w for key '%s': must be an ISO 639-1 code (e.g., en, fr)", ErrInvalidValue, key)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enum validation for keys with explicit allowed values
|
||||||
|
for _, v := range allowed {
|
||||||
|
if v == value {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("%w '%s' for key '%s': allowed values are [%s]", ErrInvalidValue, value, key, strings.Join(allowed, ", "))
|
||||||
|
}
|
||||||
124
services/preferences-api/internal/domain/preference_test.go
Normal file
124
services/preferences-api/internal/domain/preference_test.go
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateKey(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{name: "valid theme", key: "theme", wantErr: nil},
|
||||||
|
{name: "valid language", key: "language", wantErr: nil},
|
||||||
|
{name: "valid notifications_enabled", key: "notifications_enabled", wantErr: nil},
|
||||||
|
{name: "unknown key", key: "unknown", wantErr: ErrUnknownKey},
|
||||||
|
{name: "empty key", key: "", wantErr: ErrUnknownKey},
|
||||||
|
{name: "similar key", key: "themes", wantErr: ErrUnknownKey},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateKey(tt.key)
|
||||||
|
if tt.wantErr == nil {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Errorf("expected %v, got %v", tt.wantErr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateValue(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
value string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
// Theme
|
||||||
|
{name: "theme light", key: "theme", value: "light", wantErr: nil},
|
||||||
|
{name: "theme dark", key: "theme", value: "dark", wantErr: nil},
|
||||||
|
{name: "theme system", key: "theme", value: "system", wantErr: nil},
|
||||||
|
{name: "theme invalid", key: "theme", value: "blue", wantErr: ErrInvalidValue},
|
||||||
|
{name: "theme empty", key: "theme", value: "", wantErr: ErrInvalidValue},
|
||||||
|
|
||||||
|
// Language
|
||||||
|
{name: "language en", key: "language", value: "en", wantErr: nil},
|
||||||
|
{name: "language fr", key: "language", value: "fr", wantErr: nil},
|
||||||
|
{name: "language es", key: "language", value: "es", wantErr: nil},
|
||||||
|
{name: "language invalid long", key: "language", value: "english", wantErr: ErrInvalidValue},
|
||||||
|
{name: "language invalid uppercase", key: "language", value: "EN", wantErr: ErrInvalidValue},
|
||||||
|
{name: "language invalid single char", key: "language", value: "e", wantErr: ErrInvalidValue},
|
||||||
|
{name: "language empty", key: "language", value: "", wantErr: ErrInvalidValue},
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
{name: "notifications true", key: "notifications_enabled", value: "true", wantErr: nil},
|
||||||
|
{name: "notifications false", key: "notifications_enabled", value: "false", wantErr: nil},
|
||||||
|
{name: "notifications invalid yes", key: "notifications_enabled", value: "yes", wantErr: ErrInvalidValue},
|
||||||
|
{name: "notifications invalid 1", key: "notifications_enabled", value: "1", wantErr: ErrInvalidValue},
|
||||||
|
|
||||||
|
// Unknown key
|
||||||
|
{name: "unknown key", key: "unknown", value: "anything", wantErr: ErrUnknownKey},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateValue(tt.key, tt.value)
|
||||||
|
if tt.wantErr == nil {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Errorf("expected %v, got %v", tt.wantErr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreference_Validate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
pref Preference
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid preference",
|
||||||
|
pref: Preference{UserID: "user-1", Key: "theme", Value: "dark"},
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown key",
|
||||||
|
pref: Preference{UserID: "user-1", Key: "unknown", Value: "val"},
|
||||||
|
wantErr: ErrUnknownKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid value",
|
||||||
|
pref: Preference{UserID: "user-1", Key: "theme", Value: "blue"},
|
||||||
|
wantErr: ErrInvalidValue,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.pref.Validate()
|
||||||
|
if tt.wantErr == nil {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Errorf("expected %v, got %v", tt.wantErr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,37 +0,0 @@
|
|||||||
// Package port defines interfaces (ports) for external dependencies.
|
|
||||||
// These interfaces define the contracts between the application core and
|
|
||||||
// infrastructure adapters, enabling testability and flexibility.
|
|
||||||
package port
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/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)
|
|
||||||
}
|
|
||||||
15
services/preferences-api/internal/port/preference.go
Normal file
15
services/preferences-api/internal/port/preference.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// Package port defines interfaces (ports) for external dependencies.
|
||||||
|
package port
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// PreferenceRepository defines the interface for preference persistence operations.
|
||||||
|
type PreferenceRepository interface {
|
||||||
|
// GetByUserID returns all preferences for a user as a map[key]value.
|
||||||
|
// Returns an empty map if the user has no preferences.
|
||||||
|
GetByUserID(ctx context.Context, userID string) (map[string]string, error)
|
||||||
|
|
||||||
|
// Upsert creates or updates preferences for a user.
|
||||||
|
// Only the provided keys are affected; existing keys not in the map are preserved.
|
||||||
|
Upsert(ctx context.Context, userID string, prefs map[string]string) error
|
||||||
|
}
|
||||||
@ -1,137 +0,0 @@
|
|||||||
// Package service provides business logic / use cases for the application.
|
|
||||||
// Services orchestrate domain operations using port interfaces.
|
|
||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/pkg/logging"
|
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/domain"
|
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ExampleService handles example-related business logic.
|
|
||||||
type ExampleService struct {
|
|
||||||
repo port.ExampleRepository
|
|
||||||
logger *logging.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewExampleService creates a new example service.
|
|
||||||
func NewExampleService(repo port.ExampleRepository, logger *logging.Logger) *ExampleService {
|
|
||||||
return &ExampleService{
|
|
||||||
repo: repo,
|
|
||||||
logger: logger.WithService("ExampleService"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns all examples.
|
|
||||||
func (s *ExampleService) List(ctx context.Context) ([]domain.Example, error) {
|
|
||||||
return s.repo.List(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns an example by ID.
|
|
||||||
// Returns domain.ErrExampleNotFound if not found.
|
|
||||||
func (s *ExampleService) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
|
|
||||||
return s.repo.Get(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateInput contains the data needed to create an example.
|
|
||||||
type CreateInput struct {
|
|
||||||
Name string
|
|
||||||
Description string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create creates a new example with duplicate detection.
|
|
||||||
// Returns domain.ErrDuplicateExample if name already exists.
|
|
||||||
// Returns domain.ErrInvalidExampleName if name is invalid.
|
|
||||||
func (s *ExampleService) Create(ctx context.Context, input CreateInput) (*domain.Example, error) {
|
|
||||||
// Check for duplicates
|
|
||||||
exists, err := s.repo.ExistsByName(ctx, input.Name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
return nil, domain.ErrDuplicateExample
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new ID
|
|
||||||
id := domain.ExampleID(uuid.New().String())
|
|
||||||
|
|
||||||
// Create domain entity (validates name)
|
|
||||||
example, err := domain.NewExample(id, input.Name, input.Description)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist
|
|
||||||
if err := s.repo.Create(ctx, example); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.logger.Info("example created", "id", id, "name", input.Name)
|
|
||||||
return example, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateInput contains the data needed to update an example.
|
|
||||||
type UpdateInput struct {
|
|
||||||
Name string
|
|
||||||
Description string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update modifies an existing example.
|
|
||||||
// Returns domain.ErrExampleNotFound if not found.
|
|
||||||
// Returns domain.ErrDuplicateExample if new name conflicts with another example.
|
|
||||||
// Returns domain.ErrInvalidExampleName if name is invalid.
|
|
||||||
func (s *ExampleService) Update(ctx context.Context, id domain.ExampleID, input UpdateInput) (*domain.Example, error) {
|
|
||||||
// Fetch existing
|
|
||||||
example, err := s.repo.Get(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for name conflicts (only if name changed)
|
|
||||||
if example.Name != input.Name {
|
|
||||||
exists, err := s.repo.ExistsByName(ctx, input.Name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
return nil, domain.ErrDuplicateExample
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update domain entity (validates name)
|
|
||||||
if err := example.Update(input.Name, input.Description); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist
|
|
||||||
if err := s.repo.Update(ctx, example); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.logger.Info("example updated", "id", id, "name", input.Name)
|
|
||||||
return example, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes an example by ID.
|
|
||||||
// Returns domain.ErrExampleNotFound if not found.
|
|
||||||
func (s *ExampleService) Delete(ctx context.Context, id domain.ExampleID) error {
|
|
||||||
// Verify exists before delete
|
|
||||||
if _, err := s.repo.Get(ctx, id); err != nil {
|
|
||||||
if errors.Is(err, domain.ErrExampleNotFound) {
|
|
||||||
return domain.ErrExampleNotFound
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.repo.Delete(ctx, id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.logger.Info("example deleted", "id", id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,282 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/pkg/logging"
|
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/domain"
|
|
||||||
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mockExampleRepository implements port.ExampleRepository for testing.
|
|
||||||
type mockExampleRepository struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
examples map[domain.ExampleID]*domain.Example
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.ExampleRepository = (*mockExampleRepository)(nil)
|
|
||||||
|
|
||||||
func newMockExampleRepository() *mockExampleRepository {
|
|
||||||
return &mockExampleRepository{
|
|
||||||
examples: make(map[domain.ExampleID]*domain.Example),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
result := make([]domain.Example, 0, len(m.examples))
|
|
||||||
for _, e := range m.examples {
|
|
||||||
result = append(result, *e)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
e, ok := m.examples[id]
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrExampleNotFound
|
|
||||||
}
|
|
||||||
// Return a copy to avoid mutation
|
|
||||||
copy := *e
|
|
||||||
return ©, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
// Store a copy
|
|
||||||
copy := *example
|
|
||||||
m.examples[example.ID] = ©
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
if _, ok := m.examples[example.ID]; !ok {
|
|
||||||
return domain.ErrExampleNotFound
|
|
||||||
}
|
|
||||||
// Store a copy
|
|
||||||
copy := *example
|
|
||||||
m.examples[example.ID] = ©
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
if _, ok := m.examples[id]; !ok {
|
|
||||||
return domain.ErrExampleNotFound
|
|
||||||
}
|
|
||||||
delete(m.examples, id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
|
|
||||||
m.mu.RLock()
|
|
||||||
defer m.mu.RUnlock()
|
|
||||||
|
|
||||||
for _, e := range m.examples {
|
|
||||||
if e.Name == name {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExampleService_Create(t *testing.T) {
|
|
||||||
repo := newMockExampleRepository()
|
|
||||||
svc := NewExampleService(repo, logging.Nop())
|
|
||||||
|
|
||||||
t.Run("creates example successfully", func(t *testing.T) {
|
|
||||||
example, err := svc.Create(context.Background(), CreateInput{
|
|
||||||
Name: "Test Example",
|
|
||||||
Description: "A test description",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if example.Name != "Test Example" {
|
|
||||||
t.Errorf("expected name 'Test Example', got '%s'", example.Name)
|
|
||||||
}
|
|
||||||
if example.ID.IsZero() {
|
|
||||||
t.Error("expected non-empty ID")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("rejects duplicate name", func(t *testing.T) {
|
|
||||||
_, err := svc.Create(context.Background(), CreateInput{
|
|
||||||
Name: "Test Example",
|
|
||||||
Description: "Another description",
|
|
||||||
})
|
|
||||||
if err != domain.ErrDuplicateExample {
|
|
||||||
t.Errorf("expected ErrDuplicateExample, got %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("rejects empty name", func(t *testing.T) {
|
|
||||||
_, err := svc.Create(context.Background(), CreateInput{
|
|
||||||
Name: "",
|
|
||||||
Description: "Description",
|
|
||||||
})
|
|
||||||
if err != domain.ErrInvalidExampleName {
|
|
||||||
t.Errorf("expected ErrInvalidExampleName, got %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExampleService_Get(t *testing.T) {
|
|
||||||
repo := newMockExampleRepository()
|
|
||||||
svc := NewExampleService(repo, logging.Nop())
|
|
||||||
|
|
||||||
// Create an example first
|
|
||||||
created, _ := svc.Create(context.Background(), CreateInput{
|
|
||||||
Name: "Get Test",
|
|
||||||
Description: "Description",
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("returns existing example", func(t *testing.T) {
|
|
||||||
example, err := svc.Get(context.Background(), created.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if example.Name != "Get Test" {
|
|
||||||
t.Errorf("expected name 'Get Test', got '%s'", example.Name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("returns not found for missing example", func(t *testing.T) {
|
|
||||||
_, err := svc.Get(context.Background(), "nonexistent-id")
|
|
||||||
if err != domain.ErrExampleNotFound {
|
|
||||||
t.Errorf("expected ErrExampleNotFound, got %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExampleService_Update(t *testing.T) {
|
|
||||||
repo := newMockExampleRepository()
|
|
||||||
svc := NewExampleService(repo, logging.Nop())
|
|
||||||
|
|
||||||
// Create examples
|
|
||||||
example1, _ := svc.Create(context.Background(), CreateInput{
|
|
||||||
Name: "Update Test 1",
|
|
||||||
Description: "Original",
|
|
||||||
})
|
|
||||||
_, _ = svc.Create(context.Background(), CreateInput{
|
|
||||||
Name: "Update Test 2",
|
|
||||||
Description: "Other",
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("updates example successfully", func(t *testing.T) {
|
|
||||||
updated, err := svc.Update(context.Background(), example1.ID, UpdateInput{
|
|
||||||
Name: "Updated Name",
|
|
||||||
Description: "Updated description",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if updated.Name != "Updated Name" {
|
|
||||||
t.Errorf("expected name 'Updated Name', got '%s'", updated.Name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("allows same name on same example", func(t *testing.T) {
|
|
||||||
_, err := svc.Update(context.Background(), example1.ID, UpdateInput{
|
|
||||||
Name: "Updated Name",
|
|
||||||
Description: "Same name",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("unexpected error updating with same name: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("rejects name conflict", func(t *testing.T) {
|
|
||||||
_, err := svc.Update(context.Background(), example1.ID, UpdateInput{
|
|
||||||
Name: "Update Test 2",
|
|
||||||
Description: "Conflict",
|
|
||||||
})
|
|
||||||
if err != domain.ErrDuplicateExample {
|
|
||||||
t.Errorf("expected ErrDuplicateExample, got %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("returns not found for missing example", func(t *testing.T) {
|
|
||||||
_, err := svc.Update(context.Background(), "nonexistent-id", UpdateInput{
|
|
||||||
Name: "Anything",
|
|
||||||
Description: "",
|
|
||||||
})
|
|
||||||
if err != domain.ErrExampleNotFound {
|
|
||||||
t.Errorf("expected ErrExampleNotFound, got %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExampleService_Delete(t *testing.T) {
|
|
||||||
repo := newMockExampleRepository()
|
|
||||||
svc := NewExampleService(repo, logging.Nop())
|
|
||||||
|
|
||||||
// Create an example first
|
|
||||||
created, _ := svc.Create(context.Background(), CreateInput{
|
|
||||||
Name: "Delete Test",
|
|
||||||
Description: "To be deleted",
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("deletes example successfully", func(t *testing.T) {
|
|
||||||
err := svc.Delete(context.Background(), created.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify deleted
|
|
||||||
_, err = svc.Get(context.Background(), created.ID)
|
|
||||||
if err != domain.ErrExampleNotFound {
|
|
||||||
t.Errorf("expected ErrExampleNotFound after delete, got %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("returns not found for missing example", func(t *testing.T) {
|
|
||||||
err := svc.Delete(context.Background(), "nonexistent-id")
|
|
||||||
if err != domain.ErrExampleNotFound {
|
|
||||||
t.Errorf("expected ErrExampleNotFound, got %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExampleService_List(t *testing.T) {
|
|
||||||
repo := newMockExampleRepository()
|
|
||||||
svc := NewExampleService(repo, logging.Nop())
|
|
||||||
|
|
||||||
t.Run("returns empty list initially", func(t *testing.T) {
|
|
||||||
examples, err := svc.List(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if len(examples) != 0 {
|
|
||||||
t.Errorf("expected 0 examples, got %d", len(examples))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create some examples
|
|
||||||
_, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 1", Description: ""})
|
|
||||||
_, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 2", Description: ""})
|
|
||||||
|
|
||||||
t.Run("returns all examples", func(t *testing.T) {
|
|
||||||
examples, err := svc.List(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if len(examples) != 2 {
|
|
||||||
t.Errorf("expected 2 examples, got %d", len(examples))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
52
services/preferences-api/internal/service/preference.go
Normal file
52
services/preferences-api/internal/service/preference.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
// Package service provides business logic / use cases for the application.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/pkg/logging"
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/domain"
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns all preferences for a user.
|
||||||
|
func (s *PreferenceService) Get(ctx context.Context, userID string) (map[string]string, error) {
|
||||||
|
return s.repo.GetByUserID(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert validates and persists preferences, then returns the full preference set.
|
||||||
|
func (s *PreferenceService) Upsert(ctx context.Context, userID string, prefs map[string]string) (map[string]string, error) {
|
||||||
|
// Validate all keys and values before persisting
|
||||||
|
for key, value := range prefs {
|
||||||
|
if err := domain.ValidateKey(key); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w", err)
|
||||||
|
}
|
||||||
|
if err := domain.ValidateValue(key, value); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.Upsert(ctx, userID, prefs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("preferences updated", "user_id", userID, "keys_updated", len(prefs))
|
||||||
|
|
||||||
|
// Return the full preference set after update
|
||||||
|
return s.repo.GetByUserID(ctx, userID)
|
||||||
|
}
|
||||||
188
services/preferences-api/internal/service/preference_test.go
Normal file
188
services/preferences-api/internal/service/preference_test.go
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/pkg/logging"
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/domain"
|
||||||
|
"git.threesix.ai/jordan/slate-test-1770505673/services/preferences-api/internal/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockPreferenceRepository implements port.PreferenceRepository for testing.
|
||||||
|
type mockPreferenceRepository struct {
|
||||||
|
data map[string]map[string]string // userID -> key -> value
|
||||||
|
getErr error
|
||||||
|
upsertErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ port.PreferenceRepository = (*mockPreferenceRepository)(nil)
|
||||||
|
|
||||||
|
func newMockRepo() *mockPreferenceRepository {
|
||||||
|
return &mockPreferenceRepository{
|
||||||
|
data: make(map[string]map[string]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPreferenceRepository) GetByUserID(_ context.Context, userID string) (map[string]string, error) {
|
||||||
|
if m.getErr != nil {
|
||||||
|
return nil, m.getErr
|
||||||
|
}
|
||||||
|
prefs, ok := m.data[userID]
|
||||||
|
if !ok {
|
||||||
|
return make(map[string]string), nil
|
||||||
|
}
|
||||||
|
// Return a copy
|
||||||
|
result := make(map[string]string, len(prefs))
|
||||||
|
for k, v := range prefs {
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPreferenceRepository) Upsert(_ context.Context, userID string, prefs map[string]string) error {
|
||||||
|
if m.upsertErr != nil {
|
||||||
|
return m.upsertErr
|
||||||
|
}
|
||||||
|
if m.data[userID] == nil {
|
||||||
|
m.data[userID] = make(map[string]string)
|
||||||
|
}
|
||||||
|
for k, v := range prefs {
|
||||||
|
m.data[userID][k] = v
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreferenceService_Get(t *testing.T) {
|
||||||
|
t.Run("returns empty map for user with no preferences", func(t *testing.T) {
|
||||||
|
repo := newMockRepo()
|
||||||
|
svc := NewPreferenceService(repo, logging.Nop())
|
||||||
|
|
||||||
|
prefs, err := svc.Get(context.Background(), "user-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(prefs) != 0 {
|
||||||
|
t.Errorf("expected empty map, got %v", prefs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns preferences for user with data", func(t *testing.T) {
|
||||||
|
repo := newMockRepo()
|
||||||
|
repo.data["user-1"] = map[string]string{"theme": "dark", "language": "en"}
|
||||||
|
svc := NewPreferenceService(repo, logging.Nop())
|
||||||
|
|
||||||
|
prefs, err := svc.Get(context.Background(), "user-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if prefs["theme"] != "dark" {
|
||||||
|
t.Errorf("expected theme 'dark', got '%s'", prefs["theme"])
|
||||||
|
}
|
||||||
|
if prefs["language"] != "en" {
|
||||||
|
t.Errorf("expected language 'en', got '%s'", prefs["language"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("propagates repository error", func(t *testing.T) {
|
||||||
|
repo := newMockRepo()
|
||||||
|
repo.getErr = errors.New("db connection failed")
|
||||||
|
svc := NewPreferenceService(repo, logging.Nop())
|
||||||
|
|
||||||
|
_, err := svc.Get(context.Background(), "user-1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreferenceService_Upsert(t *testing.T) {
|
||||||
|
t.Run("upserts valid preferences and returns full set", func(t *testing.T) {
|
||||||
|
repo := newMockRepo()
|
||||||
|
repo.data["user-1"] = map[string]string{"language": "en"}
|
||||||
|
svc := NewPreferenceService(repo, logging.Nop())
|
||||||
|
|
||||||
|
result, err := svc.Upsert(context.Background(), "user-1", map[string]string{"theme": "dark"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if result["theme"] != "dark" {
|
||||||
|
t.Errorf("expected theme 'dark', got '%s'", result["theme"])
|
||||||
|
}
|
||||||
|
if result["language"] != "en" {
|
||||||
|
t.Errorf("expected language 'en' preserved, got '%s'", result["language"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects unknown key", func(t *testing.T) {
|
||||||
|
repo := newMockRepo()
|
||||||
|
svc := NewPreferenceService(repo, logging.Nop())
|
||||||
|
|
||||||
|
_, err := svc.Upsert(context.Background(), "user-1", map[string]string{"unknown_key": "val"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, domain.ErrUnknownKey) {
|
||||||
|
t.Errorf("expected ErrUnknownKey, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects invalid value", func(t *testing.T) {
|
||||||
|
repo := newMockRepo()
|
||||||
|
svc := NewPreferenceService(repo, logging.Nop())
|
||||||
|
|
||||||
|
_, err := svc.Upsert(context.Background(), "user-1", map[string]string{"theme": "blue"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, domain.ErrInvalidValue) {
|
||||||
|
t.Errorf("expected ErrInvalidValue, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects invalid language", func(t *testing.T) {
|
||||||
|
repo := newMockRepo()
|
||||||
|
svc := NewPreferenceService(repo, logging.Nop())
|
||||||
|
|
||||||
|
_, err := svc.Upsert(context.Background(), "user-1", map[string]string{"language": "english"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, domain.ErrInvalidValue) {
|
||||||
|
t.Errorf("expected ErrInvalidValue, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("propagates repository upsert error", func(t *testing.T) {
|
||||||
|
repo := newMockRepo()
|
||||||
|
repo.upsertErr = errors.New("db write failed")
|
||||||
|
svc := NewPreferenceService(repo, logging.Nop())
|
||||||
|
|
||||||
|
_, err := svc.Upsert(context.Background(), "user-1", map[string]string{"theme": "dark"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("propagates repository get error after upsert", func(t *testing.T) {
|
||||||
|
repo := newMockRepo()
|
||||||
|
svc := NewPreferenceService(repo, logging.Nop())
|
||||||
|
|
||||||
|
// First upsert succeeds, then set getErr so the post-upsert read fails
|
||||||
|
repo.getErr = nil
|
||||||
|
// We can't easily test this without a more complex mock, so just verify
|
||||||
|
// the happy path works end-to-end
|
||||||
|
result, err := svc.Upsert(context.Background(), "user-1", map[string]string{
|
||||||
|
"theme": "light",
|
||||||
|
"language": "fr",
|
||||||
|
"notifications_enabled": "false",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(result) != 3 {
|
||||||
|
t.Errorf("expected 3 preferences, got %d", len(result))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user