From 255de605b1398755634c6cf7ed364f5b3fe0f70f Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 13:13:02 +0200 Subject: [PATCH] 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 --- compass/config_test.go | 220 +++++++++++++++++++++++++++++++++++ compass/env_test.go | 120 +++++++++++++++++++ compass/persist_test.go | 117 +++++++++++++++++++ examples/hello/hello_test.go | 40 ++++++- party/registry_test.go | 49 ++++++++ 5 files changed, 540 insertions(+), 6 deletions(-) create mode 100644 compass/env_test.go create mode 100644 compass/persist_test.go diff --git a/compass/config_test.go b/compass/config_test.go index 7255f9d..d956498 100644 --- a/compass/config_test.go +++ b/compass/config_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "testing/fstest" ) func TestLoadStringFromAppSection(t *testing.T) { @@ -21,3 +22,222 @@ func TestLoadStringFromAppSection(t *testing.T) { 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", §ion); 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 +} diff --git a/compass/env_test.go b/compass/env_test.go new file mode 100644 index 0000000..5228005 --- /dev/null +++ b/compass/env_test.go @@ -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) + } +} diff --git a/compass/persist_test.go b/compass/persist_test.go new file mode 100644 index 0000000..e5b5c72 --- /dev/null +++ b/compass/persist_test.go @@ -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) + } +} diff --git a/examples/hello/hello_test.go b/examples/hello/hello_test.go index cf694dd..5bbfcdb 100644 --- a/examples/hello/hello_test.go +++ b/examples/hello/hello_test.go @@ -9,14 +9,42 @@ import ( "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 if err := run([]string{"greeter:hello"}, &buf); err != nil { t.Fatalf("greeter:hello: %v", err) } - if got := strings.TrimSpace(buf.String()); got != "hello-app" { - t.Fatalf("output = %q, want %q", got, "hello-app") - } + return strings.TrimSpace(buf.String()) } func TestBuiltBinaryGreeterHello(t *testing.T) { @@ -33,8 +61,8 @@ func TestBuiltBinaryGreeterHello(t *testing.T) { if err != nil { t.Fatalf("bin/hello greeter:hello: %v\n%s", err, out) } - if got := strings.TrimSpace(string(out)); got != "hello-app" { - t.Fatalf("output = %q, want %q", got, "hello-app") + if got := strings.TrimSpace(string(out)); got != "name=hello-app posts_per_page=10 debug=false" { + t.Fatalf("output = %q, want layered production config", got) } } diff --git a/party/registry_test.go b/party/registry_test.go index d928fc6..f914571 100644 --- a/party/registry_test.go +++ b/party/registry_test.go @@ -1,8 +1,12 @@ package party import ( + "io/fs" + "os" + "path/filepath" "strings" "testing" + "testing/fstest" "git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/compass" @@ -27,6 +31,51 @@ func (p *tracePlugin) Boot(*backpack.App) error { 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) { var log []string hello := &tracePlugin{id: "golem15.hello", log: &log}