test(01-02): add failing test for layered compass config

- Cover six-level precedence, env mapping, .env, Set/Persist/Reload
- Require HasConfig merge before plugin Register
- Assert hello command observes overlay and plugin defaults
This commit is contained in:
Jakub Zych
2026-09-16 13:13:02 +02:00
parent d0d04c65ac
commit 255de605b1
5 changed files with 540 additions and 6 deletions

View File

@@ -4,6 +4,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"testing/fstest"
) )
func TestLoadStringFromAppSection(t *testing.T) { func TestLoadStringFromAppSection(t *testing.T) {
@@ -21,3 +22,222 @@ func TestLoadStringFromAppSection(t *testing.T) {
t.Fatalf("String(app.name) = %q, want hello-app", got) t.Fatalf("String(app.name) = %q, want hello-app", got)
} }
} }
func TestOpenDefaultsEnvToProduction(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: base\ndebug: false\n",
"env/production/app.yaml": "debug: true\n",
"env/development/app.yaml": "name: dev\n",
})
cfg, err := Open(Options{Dir: dir, Environ: []string{}})
if err != nil {
t.Fatalf("Open: %v", err)
}
if cfg.Environment() != "production" {
t.Fatalf("Environment = %q, want production", cfg.Environment())
}
if got := cfg.String("app.name"); got != "base" {
t.Fatalf("name = %q, want base", got)
}
if !cfg.Bool("app.debug") {
t.Fatal("production overlay should set app.debug")
}
}
func TestExplicitEnvWinsOverSUMMER_ENV(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: base\n",
"env/development/app.yaml": "name: from-dev\n",
"env/staging/app.yaml": "name: from-staging\n",
})
cfg, err := Open(Options{
Dir: dir,
Env: "staging",
Environ: []string{"SUMMER_ENV=development"},
})
if err != nil {
t.Fatalf("Open: %v", err)
}
if cfg.Environment() != "staging" {
t.Fatalf("Environment = %q, want staging", cfg.Environment())
}
if got := cfg.String("app.name"); got != "from-staging" {
t.Fatalf("name = %q, want from-staging", got)
}
}
func TestDeepMergeKeepsUntouchedNestedKeys(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"database.yaml": `
host: localhost
port: 5432
pool:
max: 10
idle: 2
`,
"env/development/database.yaml": `
host: dev-db
pool:
max: 20
`,
})
cfg, err := Open(Options{Dir: dir, Env: "development", Environ: []string{}})
if err != nil {
t.Fatalf("Open: %v", err)
}
if got := cfg.String("database.host"); got != "dev-db" {
t.Fatalf("host = %q, want dev-db", got)
}
if got := cfg.Int("database.port"); got != 5432 {
t.Fatalf("port = %d, want 5432", got)
}
if got := cfg.Int("database.pool.max"); got != 20 {
t.Fatalf("pool.max = %d, want 20", got)
}
if got := cfg.Int("database.pool.idle"); got != 2 {
t.Fatalf("pool.idle = %d, want 2", got)
}
}
func TestMergePluginConfigAtBarePluginID(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: hello-app\n",
})
cfg, err := Open(Options{Dir: dir, Environ: []string{}})
if err != nil {
t.Fatalf("Open: %v", err)
}
fsys := fstest.MapFS{
"config/config.yaml": {Data: []byte("posts_per_page: 10\nshow_author: true\n")},
}
if err := cfg.MergePlugin("golem15.hello", fsys); err != nil {
t.Fatalf("MergePlugin: %v", err)
}
if got := cfg.Int("golem15.hello.posts_per_page"); got != 10 {
t.Fatalf("posts_per_page = %d, want 10", got)
}
if !cfg.Bool("golem15.hello.show_author") {
t.Fatal("show_author should be true")
}
if !cfg.Has("golem15.hello.posts_per_page") {
t.Fatal("Has(golem15.hello.posts_per_page) = false")
}
got, ok := cfg.Lookup("golem15.hello.posts_per_page")
if !ok {
t.Fatal("Lookup missing posts_per_page")
}
switch v := got.(type) {
case int:
if v != 10 {
t.Fatalf("Lookup = %v, want 10", got)
}
case int64:
if v != 10 {
t.Fatalf("Lookup = %v, want 10", got)
}
case uint64:
if v != 10 {
t.Fatalf("Lookup = %v, want 10", got)
}
default:
// YAML may decode as int or the getter path may return the raw value.
if cfg.Int("golem15.hello.posts_per_page") != 10 {
t.Fatalf("Lookup type %T value %v", got, got)
}
}
var section struct {
PostsPerPage int `koanf:"posts_per_page"`
ShowAuthor bool `koanf:"show_author"`
}
if err := cfg.LoadSection("golem15.hello", &section); err != nil {
t.Fatalf("LoadSection: %v", err)
}
if section.PostsPerPage != 10 || !section.ShowAuthor {
t.Fatalf("LoadSection = %+v", section)
}
}
func TestEnvVarsOverridePluginDefaults(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: hello-app\n",
})
cfg, err := Open(Options{
Dir: dir,
Environ: []string{
"SUMMER_GOLEM15__HELLO__POSTS_PER_PAGE=25",
},
})
if err != nil {
t.Fatalf("Open: %v", err)
}
fsys := fstest.MapFS{
"config/config.yaml": {Data: []byte("posts_per_page: 10\n")},
}
if err := cfg.MergePlugin("golem15.hello", fsys); err != nil {
t.Fatalf("MergePlugin: %v", err)
}
if got := cfg.Int("golem15.hello.posts_per_page"); got != 25 {
t.Fatalf("posts_per_page = %d, want 25 (env over plugin default)", got)
}
}
func TestPriorityRuntimeSetThenPersistThenEnvThenOverlayThenBaseThenPlugin(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: base\n",
"env/development/app.yaml": "name: overlay\n",
"env/development/overrides.yaml": "app:\n name: persisted\n",
})
cfg, err := Open(Options{
Dir: dir,
Env: "development",
Environ: []string{"SUMMER_APP__NAME=from-env"},
})
if err != nil {
t.Fatalf("Open: %v", err)
}
if got := cfg.String("app.name"); got != "persisted" {
t.Fatalf("before Set, name = %q, want persisted", got)
}
if err := cfg.Set("app.name", "runtime"); err != nil {
t.Fatalf("Set: %v", err)
}
if got := cfg.String("app.name"); got != "runtime" {
t.Fatalf("after Set, name = %q, want runtime", got)
}
}
func TestSortedSectionFilesLoadDeterministically(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"b.yaml": "value: b\n",
"a.yaml": "value: a\nshared: 1\n",
})
cfg, err := Open(Options{Dir: dir, Environ: []string{}})
if err != nil {
t.Fatalf("Open: %v", err)
}
if got := cfg.String("a.value"); got != "a" {
t.Fatalf("a.value = %q, want a", got)
}
if got := cfg.String("b.value"); got != "b" {
t.Fatalf("b.value = %q, want b", got)
}
if got := cfg.Int("a.shared"); got != 1 {
t.Fatalf("a.shared = %d, want 1", got)
}
}
func writeConfigTree(t *testing.T, files map[string]string) string {
t.Helper()
dir := t.TempDir()
for name, body := range files {
path := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
return dir
}

120
compass/env_test.go Normal file
View File

@@ -0,0 +1,120 @@
package compass
import (
"os"
"path/filepath"
"testing"
)
func TestEnvDoubleUnderscoreMapsToDotPath(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"database.yaml": "host: localhost\n",
"app.yaml": "name: base\n",
})
cfg, err := Open(Options{
Dir: dir,
Environ: []string{
"SUMMER_DATABASE__HOST=from-env",
"SUMMER_GOLEM15__HELLO__POSTS_PER_PAGE=25",
"IGNORED_VALUE=nope",
},
})
if err != nil {
t.Fatalf("Open: %v", err)
}
if got := cfg.String("database.host"); got != "from-env" {
t.Fatalf("database.host = %q, want from-env", got)
}
if got := cfg.Int("golem15.hello.posts_per_page"); got != 25 {
t.Fatalf("golem15.hello.posts_per_page = %d, want 25", got)
}
if cfg.Has("ignored_value") || cfg.Has("IGNORED_VALUE") {
t.Fatal("non-SUMMER_ variables must not enter config")
}
}
func TestSingleUnderscoreRemainsLiteralLeaf(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: base\n",
})
cfg, err := Open(Options{
Dir: dir,
Environ: []string{
"SUMMER_GOLEM15__HELLO__POSTS_PER_PAGE=9",
},
})
if err != nil {
t.Fatalf("Open: %v", err)
}
if !cfg.Has("golem15.hello.posts_per_page") {
t.Fatal("expected golem15.hello.posts_per_page")
}
if cfg.Has("golem15.hello.posts.per.page") {
t.Fatal("single underscores must not become path separators")
}
if got := cfg.Int("golem15.hello.posts_per_page"); got != 9 {
t.Fatalf("posts_per_page = %d, want 9", got)
}
}
func TestDotEnvFillsOnlyAbsentRealEnvKeysAndDoesNotMutateProcess(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "config")
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: base\n"), 0o644); err != nil {
t.Fatal(err)
}
dotenv := "SUMMER_APP__NAME=from-dotenv\nSUMMER_DATABASE__HOST=dotenv-host\n"
if err := os.WriteFile(filepath.Join(root, ".env"), []byte(dotenv), 0o600); err != nil {
t.Fatal(err)
}
const sentinel = "SUMMER_COMPASS_DOTENV_SENTINEL"
if _, ok := os.LookupEnv(sentinel); ok {
t.Fatalf("%s already set in process env", sentinel)
}
cfg, err := Open(Options{
Dir: dir,
Environ: []string{
"SUMMER_APP__NAME=from-real",
},
})
if err != nil {
t.Fatalf("Open: %v", err)
}
if got := cfg.String("app.name"); got != "from-real" {
t.Fatalf("app.name = %q, want from-real (real env wins over .env)", got)
}
if got := cfg.String("database.host"); got != "dotenv-host" {
t.Fatalf("database.host = %q, want dotenv-host (.env fills absent keys)", got)
}
if _, ok := os.LookupEnv("SUMMER_DATABASE__HOST"); ok {
t.Fatal("Open must not mutate process environment with .env values")
}
if _, ok := os.LookupEnv(sentinel); ok {
t.Fatal("Open must not mutate process environment")
}
}
func TestSUMMER_ENVFromProcessSelectsOverlay(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: base\n",
"env/development/app.yaml": "name: hello-dev\n",
})
cfg, err := Open(Options{
Dir: dir,
Environ: []string{"SUMMER_ENV=development"},
})
if err != nil {
t.Fatalf("Open: %v", err)
}
if cfg.Environment() != "development" {
t.Fatalf("Environment = %q, want development", cfg.Environment())
}
if got := cfg.String("app.name"); got != "hello-dev" {
t.Fatalf("name = %q, want hello-dev", got)
}
}

117
compass/persist_test.go Normal file
View File

@@ -0,0 +1,117 @@
package compass
import (
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
)
func TestSetWinsOverPersistedOverrides(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: base\n",
"env/development/overrides.yaml": "app:\n name: persisted\n",
})
cfg, err := Open(Options{Dir: dir, Env: "development", Environ: []string{}})
if err != nil {
t.Fatalf("Open: %v", err)
}
if got := cfg.String("app.name"); got != "persisted" {
t.Fatalf("loaded persisted name = %q, want persisted", got)
}
if err := cfg.Set("app.name", "runtime"); err != nil {
t.Fatalf("Set: %v", err)
}
if got := cfg.String("app.name"); got != "runtime" {
t.Fatalf("Set should win, got %q", got)
}
}
func TestPersistWritesEnvOverridesAtomicallyWithRestrictivePerms(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: base\n",
})
cfg, err := Open(Options{Dir: dir, Env: "development", Environ: []string{}})
if err != nil {
t.Fatalf("Open: %v", err)
}
if err := cfg.Set("app.name", "saved"); err != nil {
t.Fatalf("Set: %v", err)
}
if err := cfg.Persist(); err != nil {
t.Fatalf("Persist: %v", err)
}
path := filepath.Join(dir, "env", "development", "overrides.yaml")
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat overrides: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("overrides perm = %o, want 0600", info.Mode().Perm())
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), "saved") {
t.Fatalf("overrides.yaml = %q, want to contain saved", body)
}
tmp := filepath.Join(dir, "env", "development", "overrides.yaml.tmp")
if _, err := os.Stat(tmp); !os.IsNotExist(err) {
t.Fatalf("temporary persist file should not remain: %v", err)
}
}
func TestReloadClearsSetAndRereadsFiles(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: base\n",
})
cfg, err := Open(Options{Dir: dir, Env: "development", Environ: []string{}})
if err != nil {
t.Fatalf("Open: %v", err)
}
fsys := fstest.MapFS{
"config/config.yaml": {Data: []byte("posts_per_page: 10\n")},
}
if err := cfg.MergePlugin("golem15.hello", fsys); err != nil {
t.Fatalf("MergePlugin: %v", err)
}
if err := cfg.Set("app.name", "runtime"); err != nil {
t.Fatalf("Set: %v", err)
}
if err := cfg.Persist(); err != nil {
t.Fatalf("Persist: %v", err)
}
if err := cfg.Set("app.name", "runtime2"); err != nil {
t.Fatalf("Set runtime2: %v", err)
}
if err := cfg.Set("golem15.hello.posts_per_page", 99); err != nil {
t.Fatalf("Set posts: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: disk\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := cfg.Reload(); err != nil {
t.Fatalf("Reload: %v", err)
}
if got := cfg.String("app.name"); got != "runtime" {
t.Fatalf("after Reload, name = %q, want persisted runtime (Set cleared)", got)
}
if got := cfg.Int("golem15.hello.posts_per_page"); got != 10 {
t.Fatalf("after Reload, posts_per_page = %d, want plugin default 10", got)
}
}
func TestPersistRejectsInvalidEnvName(t *testing.T) {
dir := writeConfigTree(t, map[string]string{
"app.yaml": "name: base\n",
})
_, err := Open(Options{Dir: dir, Env: "../etc", Environ: []string{}})
if err == nil {
t.Fatal("expected invalid environment name to fail")
}
if strings.Contains(strings.ToLower(err.Error()), "etc") {
t.Fatalf("error should not echo unsanitized env path: %v", err)
}
}

View File

@@ -9,14 +9,42 @@ import (
"testing" "testing"
) )
func TestGreeterHelloPrintsAppName(t *testing.T) { func TestGreeterHelloPrintsLayeredConfig(t *testing.T) {
got := runHello(t, nil)
want := "name=hello-app posts_per_page=10 debug=false"
if got != want {
t.Fatalf("output = %q, want %q", got, want)
}
}
func TestGreeterHelloSeesDevelopmentOverlay(t *testing.T) {
got := runHello(t, map[string]string{"SUMMER_ENV": "development"})
want := "name=hello-dev posts_per_page=10 debug=true"
if got != want {
t.Fatalf("output = %q, want %q", got, want)
}
}
func TestGreeterHelloSeesEnvVarOverrideOfPluginDefault(t *testing.T) {
got := runHello(t, map[string]string{
"SUMMER_GOLEM15__HELLO__POSTS_PER_PAGE": "25",
})
want := "name=hello-app posts_per_page=25 debug=false"
if got != want {
t.Fatalf("output = %q, want %q", got, want)
}
}
func runHello(t *testing.T, env map[string]string) string {
t.Helper()
for k, v := range env {
t.Setenv(k, v)
}
var buf bytes.Buffer var buf bytes.Buffer
if err := run([]string{"greeter:hello"}, &buf); err != nil { if err := run([]string{"greeter:hello"}, &buf); err != nil {
t.Fatalf("greeter:hello: %v", err) t.Fatalf("greeter:hello: %v", err)
} }
if got := strings.TrimSpace(buf.String()); got != "hello-app" { return strings.TrimSpace(buf.String())
t.Fatalf("output = %q, want %q", got, "hello-app")
}
} }
func TestBuiltBinaryGreeterHello(t *testing.T) { func TestBuiltBinaryGreeterHello(t *testing.T) {
@@ -33,8 +61,8 @@ func TestBuiltBinaryGreeterHello(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("bin/hello greeter:hello: %v\n%s", err, out) t.Fatalf("bin/hello greeter:hello: %v\n%s", err, out)
} }
if got := strings.TrimSpace(string(out)); got != "hello-app" { if got := strings.TrimSpace(string(out)); got != "name=hello-app posts_per_page=10 debug=false" {
t.Fatalf("output = %q, want %q", got, "hello-app") t.Fatalf("output = %q, want layered production config", got)
} }
} }

View File

@@ -1,8 +1,12 @@
package party package party
import ( import (
"io/fs"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/compass" "git.golem15.com/golem15/summercms/compass"
@@ -27,6 +31,51 @@ func (p *tracePlugin) Boot(*backpack.App) error {
return nil return nil
} }
type configPlugin struct {
id string
fsys fs.FS
got *int
}
func (p *configPlugin) ID() string { return p.id }
func (p *configPlugin) Requires() []string { return nil }
func (p *configPlugin) ConfigFS() fs.FS { return p.fsys }
func (p *configPlugin) Register(app *backpack.App) error {
if app != nil && app.Config != nil {
*p.got = app.Config.Int("golem15.hello.posts_per_page")
}
return nil
}
func (p *configPlugin) Boot(*backpack.App) error { return nil }
func TestActivateMergesHasConfigBeforeRegister(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: app\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Load(dir)
if err != nil {
t.Fatalf("Load: %v", err)
}
var got int
p := &configPlugin{
id: "golem15.hello",
got: &got,
fsys: fstest.MapFS{
"config/config.yaml": {Data: []byte("posts_per_page: 10\n")},
},
}
app := backpack.New(cfg)
if _, err := activate([]Plugin{p}, app, []string{"golem15.hello"}); err != nil {
t.Fatalf("activate: %v", err)
}
if got != 10 {
t.Fatalf("posts_per_page during Register = %d, want 10 (HasConfig must merge before Register)", got)
}
}
func TestActivateRunsAllRegisterBeforeAnyBoot(t *testing.T) { func TestActivateRunsAllRegisterBeforeAnyBoot(t *testing.T) {
var log []string var log []string
hello := &tracePlugin{id: "golem15.hello", log: &log} hello := &tracePlugin{id: "golem15.hello", log: &log}