From 54f0deadbc72562c60b7810092889ae8777611c7 Mon Sep 17 00:00:00 2001 From: Huxley Date: Mon, 27 Apr 2026 16:55:58 +0800 Subject: [PATCH] Add initial framework of configuration. feat: configuration file system of app. Note: still work in progress. --- config.example.yaml | 14 +++ docs/architecture.md | 73 ++++++++++--- docs/decisions.md | 33 +++--- docs/roadmap.md | 39 ++++--- go.mod | 16 ++- go.sum | 43 ++++++++ internal/config/config.go | 74 +++++++++++++ internal/config/load.go | 66 ++++++++++++ internal/config/load_test.go | 197 +++++++++++++++++++++++++++++++++++ 9 files changed, 515 insertions(+), 40 deletions(-) create mode 100644 internal/config/config.go create mode 100644 internal/config/load.go create mode 100644 internal/config/load_test.go diff --git a/config.example.yaml b/config.example.yaml index 93860d9..43f314d 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,3 +1,17 @@ server: host: 0.0.0.0 port: 10086 + +database: + driver: sqlite3 + path: data/mygo.db + +storage: + driver: local + local: + path: data/files + +jwt: + secret: change-me-in-production + access_ttl: 15m + refresh_ttl: 168h diff --git a/docs/architecture.md b/docs/architecture.md index 6b79e96..c5865ed 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,22 +1,67 @@ # Architecture -## Code Layout +## Layered Design ``` -main.go — entrypoint, calls cmd.Execute() -cmd/ — cobra commands (root + subcommands) -internal/ — private application packages -docs/ — project documentation +Handler (Gin handlers) ← translates HTTP ↔ Service calls + ↓ +Service (business logic) ← orchestrates, authorizes, validates + ↓ ↓ +Repository (GORM data access) Storage (file I/O) + ↓ ↓ +[SQLite / PostgreSQL] [Local FS / S3] ``` -## Packages +Rules: +- Handler has no business logic — parse request, call service, write response. +- Service has no HTTP awareness — operates on domain models and interfaces. +- Repository abstracts the database; Storage abstracts where bytes live. +- `internal/server` is the composition root — wires all dependencies together. -| Package | Purpose | Status | -|---------|---------|--------| -| `cmd` | CLI command definitions | ✅ skeleton | -| `internal/config` | Configuration loading and parsing | ⬜ planned | -| `internal/controllers` | HTTP request handlers | ⬜ planned | -| `internal/auth` | Authentication (JWT) | ⬜ planned | -| `internal/storage` | File storage abstraction | ⬜ planned | +## Package Map -Add new rows as packages are created. +| Layer | Package | Purpose | Status | +|-------|---------|---------|--------| +| **CLI** | `cmd` | Cobra root command | ✅ skeleton | +| | `cmd/serve.go` | `mygo serve` — wire deps, start HTTP | ⬜ plan | +| | `cmd/config.go` | `mygo config` — config subcommand | ⬜ plan | +| | `cmd/status.go` | `mygo status` — health check | ⬜ plan | +| **Config** | `internal/config` | Viper load (YAML + env + flags) | ⬜ plan | +| **HTTP** | `internal/server` | Gin engine init, route registration, middleware chain | ⬜ plan | +| | `internal/handler` | HTTP handlers (auth, file, admin, webdav...) | ⬜ plan | +| | `internal/middleware` | Gin middleware (requestid, logger, cors, auth) | ⬜ plan | +| **Business** | `internal/service` | Business logic (auth, file, admin) | ⬜ plan | +| | `internal/model` | Domain types (User, File, errors) | ⬜ plan | +| **Data** | `internal/repository` | Repository interfaces + GORM implementations | ⬜ plan | +| | `internal/storage` | Storage backend interface + local disk impl | ⬜ plan | +| **Util** | `internal/auth` | JWT sign/verify, context helpers | ⬜ plan | +| | `internal/api` | JSON response writer, error code helpers | ⬜ plan | + +## API Routes (v0) + +``` +POST /api/v1/auth/register +POST /api/v1/auth/login +POST /api/v1/auth/refresh + +GET /api/v1/users/me +PATCH /api/v1/users/me + +GET /api/v1/files +POST /api/v1/files +GET /api/v1/files/:id +GET /api/v1/files/:id/content +PUT /api/v1/files/:id +DELETE /api/v1/files/:id + +GET /api/v1/admin/users +GET /api/v1/admin/users/:id +PUT /api/v1/admin/users/:id +DELETE /api/v1/admin/users/:id +``` + +## Middleware Chain + +Applied globally: requestid → logger → cors + +Applied to protected groups: auth (JWT validation, inject user into gin.Context) diff --git a/docs/decisions.md b/docs/decisions.md index 52aa9e8..03430f1 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1,19 +1,28 @@ # Technical Decisions -Record significant technical decisions as they are made. Use the format below. +## 2026-04-25: v0 Tech Stack & Architecture -## Template +**Context**: Project skeleton was created with only cobra CLI. We needed a concrete tech stack and package layout to begin implementation. -``` -## YYYY-MM-DD: Title +**Decisions**: -**Context**: Why this decision was needed. +| Area | Choice | Rationale | +|------|--------|-----------| +| HTTP framework | Gin | Most widely adopted Go web framework, mature middleware ecosystem | +| ORM | GORM | SQLite-first dev, PostgreSQL option later; GORM abstracts dialect differences | +| Config management | Viper | YAML + env vars + CLI flags three-way merge, built for cobra integration | +| Database | SQLite (v0) → PostgreSQL (future) | SQLite zero setup for dev; repo interface isolates the switch | +| File storage | Local disk (v0) → S3 (future) | Backend interface (`internal/storage`) hides implementation | +| File identity | UUID | Distributed-friendly, no coordination needed; cost is negligible for file metadata | +| Token strategy | JWT, refresh token stored in DB | Enables server-side revocation (admin kick, logout-all-devices) | +| Pagination | OFFSET/LIMIT | Simple, sufficient for v0; migrate to cursor-based if needed | +| API response format | `{code, message, data}` | Consistent envelope across all endpoints | -**Decision**: What was decided. +**Architecture**: Four-layer model — Handler (Gin) → Service (business logic) → Repository (GORM data access) + Storage (file I/O). Each layer depends only on interfaces of the layer below. -**Consequences**: What this means for the project. -``` - -## Decisions - -*(None yet. Add entries as decisions are made.)* +**Consequences**: +- Handler layer has no business logic; Service layer is reusable across REST API, WebDAV, and future Nextcloud API. +- Repository interfaces keep DB swappable; future PostgreSQL implementation only needs a new package. +- Refresh token in DB adds a `sessions` table and a `repository.SessionRepository` interface. +- UUID dependency: `github.com/google/uuid` to be added. +- Gin middleware chain: requestid → logger → cors → auth (route-group-scoped). diff --git a/docs/roadmap.md b/docs/roadmap.md index 29a811b..4ff6b2d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,24 +1,37 @@ # Roadmap -See `README.md` for high-level feature list. This file tracks detailed progress. - ## v0 | Feature | Status | Notes | |---------|--------|-------| -| CLI config management | ⬜ planned | | -| JWT authentication | ⬜ planned | | -| File upload/download/manage APIs | ⬜ planned | | -| Admin endpoints | ⬜ planned | | -| WebDAV | ⬜ planned | | +| CLI config management | ⬜ plan | | +| JWT authentication | ⬜ plan | access + refresh tokens, refresh token in DB | +| File upload/download/manage APIs | ⬜ plan | REST API via Gin | +| Admin endpoints | ⬜ plan | user CRUD for superusers | +| WebDAV | ⬜ plan | future v0 or v1 | + +## Implementation Tasks + +Package-level implementation order (each task includes unit tests): + +1. `internal/config` — Viper loader, config struct +2. `internal/model` — domain types, error codes +3. `internal/api` — JSON response helpers +4. `internal/auth` — JWT utils +5. `internal/storage` — backend interface + local fs +6. `internal/repository` — interfaces + GORM/SQLite impl +7. `internal/service` — auth, file, admin services +8. `internal/middleware` — requestid, logger, cors, auth +9. `internal/handler` — auth, file, admin handlers +10. `internal/server` — Gin wiring, route registration +11. `cmd/serve.go`, `cmd/config.go`, `cmd/status.go` +12. Integration tests ## Future | Feature | Status | Notes | |---------|--------|-------| -| Image server | ⬜ planned | | -| Pastebin & code snippets | ⬜ planned | | -| S3 storage backend | ⬜ planned | | -| Nextcloud-compatible API | ⬜ planned | | - -Update status after completing related work. +| Image server | ⬜ plan | thumbnail generation | +| Pastebin & code snippets | ⬜ plan | in sharing context | +| S3 storage backend | ⬜ plan | new storage impl | +| Nextcloud-compatible API | ⬜ plan | new handler layer on existing services | diff --git a/go.mod b/go.mod index 5fa65c8..d341181 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,23 @@ module github.com/dhao2001/mygo go 1.26.2 -require github.com/spf13/cobra v1.10.2 +require ( + github.com/go-viper/mapstructure/v2 v2.4.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 +) require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index ef5d78d..de19da9 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,54 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0614348 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,74 @@ +package config + +import ( + "errors" + "fmt" + "net" + "time" +) + +type Config struct { + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` + Storage StorageConfig `mapstructure:"storage"` + JWT JWTConfig `mapstructure:"jwt"` +} + +type ServerConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` +} + +type DatabaseConfig struct { + Driver string `mapstructure:"driver"` + Path string `mapstructure:"path"` +} + +type StorageConfig struct { + Driver string `mapstructure:"driver"` + Local LocalStorageConfig `mapstructure:"local"` +} + +type LocalStorageConfig struct { + Path string `mapstructure:"path"` +} + +type JWTConfig struct { + Secret string `mapstructure:"secret"` + AccessTTL time.Duration `mapstructure:"access_ttl"` + RefreshTTL time.Duration `mapstructure:"refresh_ttl"` +} + +func (c *Config) Validate() error { + var errs []error + + if c.Server.Port < 1 || c.Server.Port > 65535 { + errs = append(errs, fmt.Errorf("server.port: %d out of range [1, 65535]", c.Server.Port)) + } + + if addr := net.ParseIP(c.Server.Host); c.Server.Host != "" && addr == nil { + errs = append(errs, fmt.Errorf("server.host: %q is not a valid IP address", c.Server.Host)) + } + + if c.Database.Path == "" { + errs = append(errs, errors.New("database.path: must not be empty")) + } + + if c.Storage.Local.Path == "" { + errs = append(errs, errors.New("storage.local.path: must not be empty")) + } + + if c.JWT.Secret == "" { + errs = append(errs, errors.New("jwt.secret: must not be empty")) + } + + if c.JWT.AccessTTL <= 0 { + errs = append(errs, fmt.Errorf("jwt.access_ttl: %v must be positive", c.JWT.AccessTTL)) + } + + if c.JWT.RefreshTTL <= 0 { + errs = append(errs, fmt.Errorf("jwt.refresh_ttl: %v must be positive", c.JWT.RefreshTTL)) + } + + return errors.Join(errs...) +} diff --git a/internal/config/load.go b/internal/config/load.go new file mode 100644 index 0000000..d306023 --- /dev/null +++ b/internal/config/load.go @@ -0,0 +1,66 @@ +package config + +import ( + "errors" + "fmt" + "strings" + + "github.com/go-viper/mapstructure/v2" + "github.com/spf13/viper" +) + +func defaults(v *viper.Viper) { + v.SetDefault("server.host", "0.0.0.0") + v.SetDefault("server.port", 10086) + + v.SetDefault("database.driver", "sqlite3") + v.SetDefault("database.path", "data/mygo.db") + + v.SetDefault("storage.driver", "local") + v.SetDefault("storage.local.path", "data/files") + + v.SetDefault("jwt.secret", "dev-secret-do-not-use-in-production") + v.SetDefault("jwt.access_ttl", "15m") + v.SetDefault("jwt.refresh_ttl", "168h") +} + +func decodeHook() viper.DecoderConfigOption { + return viper.DecodeHook(mapstructure.StringToTimeDurationHookFunc()) +} + +func New() *viper.Viper { + v := viper.New() + v.SetEnvPrefix("MYGO") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + defaults(v) + return v +} + +func Load(v *viper.Viper, cfgFile string) (*Config, error) { + if cfgFile != "" { + v.SetConfigFile(cfgFile) + } else { + v.SetConfigName("config") + v.SetConfigType("yaml") + v.AddConfigPath(".") + } + + if err := v.ReadInConfig(); err != nil { + var notFound viper.ConfigFileNotFoundError + if !errors.As(err, ¬Found) { + return nil, fmt.Errorf("read config: %w", err) + } + } + + var cfg Config + if err := v.Unmarshal(&cfg, decodeHook()); err != nil { + return nil, fmt.Errorf("unmarshal config: %w", err) + } + + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("validate config: %w", err) + } + + return &cfg, nil +} diff --git a/internal/config/load_test.go b/internal/config/load_test.go new file mode 100644 index 0000000..dd3fc7d --- /dev/null +++ b/internal/config/load_test.go @@ -0,0 +1,197 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestDefaults(t *testing.T) { + v := New() + cfg, err := Load(v, "") + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + got any + want any + }{ + {"server.host", cfg.Server.Host, "0.0.0.0"}, + {"server.port", cfg.Server.Port, 10086}, + {"database.driver", cfg.Database.Driver, "sqlite3"}, + {"database.path", cfg.Database.Path, "data/mygo.db"}, + {"storage.driver", cfg.Storage.Driver, "local"}, + {"storage.local.path", cfg.Storage.Local.Path, "data/files"}, + {"jwt.access_ttl", cfg.JWT.AccessTTL, 15 * time.Minute}, + {"jwt.refresh_ttl", cfg.JWT.RefreshTTL, 168 * time.Hour}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Errorf("got %v, want %v", tt.got, tt.want) + } + }) + } +} + +func TestFromYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + yaml := ` +server: + host: 127.0.0.1 + port: 9090 + +database: + driver: sqlite3 + path: /tmp/mygo.db + +storage: + driver: local + local: + path: /tmp/mygo-storage + +jwt: + secret: test-secret + access_ttl: 30m + refresh_ttl: 72h +` + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + + v := New() + cfg, err := Load(v, path) + if err != nil { + t.Fatal(err) + } + + if cfg.Server.Host != "127.0.0.1" { + t.Errorf("server.host = %q, want %q", cfg.Server.Host, "127.0.0.1") + } + if cfg.Server.Port != 9090 { + t.Errorf("server.port = %d, want %d", cfg.Server.Port, 9090) + } + if cfg.Database.Path != "/tmp/mygo.db" { + t.Errorf("database.path = %q, want %q", cfg.Database.Path, "/tmp/mygo.db") + } + if cfg.Storage.Local.Path != "/tmp/mygo-storage" { + t.Errorf("storage.local.path = %q, want %q", cfg.Storage.Local.Path, "/tmp/mygo-storage") + } + if cfg.JWT.Secret != "test-secret" { + t.Errorf("jwt.secret = %q, want %q", cfg.JWT.Secret, "test-secret") + } + if cfg.JWT.AccessTTL != 30*time.Minute { + t.Errorf("jwt.access_ttl = %v, want %v", cfg.JWT.AccessTTL, 30*time.Minute) + } + if cfg.JWT.RefreshTTL != 72*time.Hour { + t.Errorf("jwt.refresh_ttl = %v, want %v", cfg.JWT.RefreshTTL, 72*time.Hour) + } +} + +func TestEnvOverride(t *testing.T) { + t.Setenv("MYGO_SERVER_PORT", "8080") + t.Setenv("MYGO_SERVER_HOST", "192.168.1.1") + t.Setenv("MYGO_JWT_SECRET", "env-secret") + t.Setenv("MYGO_DATABASE_PATH", "/env/path/db.sqlite") + + v := New() + cfg, err := Load(v, "") + if err != nil { + t.Fatal(err) + } + + if cfg.Server.Port != 8080 { + t.Errorf("server.port = %d, want %d", cfg.Server.Port, 8080) + } + if cfg.Server.Host != "192.168.1.1" { + t.Errorf("server.host = %q, want %q", cfg.Server.Host, "192.168.1.1") + } + if cfg.JWT.Secret != "env-secret" { + t.Errorf("jwt.secret = %q, want %q", cfg.JWT.Secret, "env-secret") + } + if cfg.Database.Path != "/env/path/db.sqlite" { + t.Errorf("database.path = %q, want %q", cfg.Database.Path, "/env/path/db.sqlite") + } +} + +func TestEnvOverridesYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + yaml := ` +server: + host: 0.0.0.0 + port: 10086 +` + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + + t.Setenv("MYGO_SERVER_PORT", "9999") + + v := New() + cfg, err := Load(v, path) + if err != nil { + t.Fatal(err) + } + + if cfg.Server.Port != 9999 { + t.Errorf("server.port = %d, want %d (env should override YAML)", cfg.Server.Port, 9999) + } + if cfg.Server.Host != "0.0.0.0" { + t.Errorf("server.host = %q, want %q (YAML should be used when env is absent)", cfg.Server.Host, "0.0.0.0") + } +} + +func TestValidatePortRange(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + yaml := ` +server: + host: 0.0.0.0 + port: 0 +` + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + + v := New() + _, err := Load(v, path) + if err == nil { + t.Fatal("expected error for port 0, got nil") + } +} + +func TestValidateEmptySecret(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + yaml := ` +jwt: + secret: "" +` + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + + v := New() + _, err := Load(v, path) + if err == nil { + t.Fatal("expected error for empty jwt.secret, got nil") + } +} + +func TestExplicitConfigFileNotFound(t *testing.T) { + v := New() + _, err := Load(v, "/nonexistent/path/config.yaml") + if err == nil { + t.Fatal("expected error when explicitly specifying a nonexistent config file") + } +}