Files
weatherreporter/internal/config/config_test.go

2136 lines
62 KiB
Go

package config
import (
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
"gopkg.in/yaml.v3"
)
func TestDefaults(t *testing.T) {
cfg, err := Load(LoadOptions{})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.WeatherAPI.Units != "us" {
t.Fatalf("Units = %q, want us", cfg.WeatherAPI.Units)
}
if cfg.WeatherAPI.Timezone != "America/Chicago" {
t.Fatalf("Timezone = %q, want America/Chicago", cfg.WeatherAPI.Timezone)
}
if cfg.WeatherAPI.Format != "json" {
t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format)
}
if cfg.WeatherAPI.Precision != 0 {
t.Fatalf("Precision = %d, want 0", cfg.WeatherAPI.Precision)
}
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
t.Fatalf("Location = %#v, want home/Brentwood/St. Louis Metro", cfg.Location)
}
if cfg.Secrets.Directory != "" {
t.Fatalf("Secrets.Directory = %q, want empty", cfg.Secrets.Directory)
}
if cfg.Output.Directory != "" {
t.Fatalf("Output.Directory = %q, want empty", cfg.Output.Directory)
}
if cfg.Notify.Distributor.Enabled {
t.Fatalf("Notify.Distributor.Enabled = true, want false")
}
if cfg.Notify.Distributor.Endpoint != "https://distributor.example.com" {
t.Fatalf("Notify.Distributor.Endpoint = %q, want default endpoint", cfg.Notify.Distributor.Endpoint)
}
if cfg.Notify.Distributor.TokenEnv != "DISTRIBUTOR_UPLOAD_TOKEN" {
t.Fatalf("Notify.Distributor.TokenEnv = %q, want DISTRIBUTOR_UPLOAD_TOKEN", cfg.Notify.Distributor.TokenEnv)
}
if cfg.Notify.Distributor.Timeout != 30*time.Second {
t.Fatalf("Notify.Distributor.Timeout = %s, want 30s", cfg.Notify.Distributor.Timeout)
}
if cfg.Notify.Distributor.FailurePolicy != NotifyFailureError {
t.Fatalf("Notify.Distributor.FailurePolicy = %q, want error", cfg.Notify.Distributor.FailurePolicy)
}
if cfg.Notify.Distributor.PipelineIDTemplate != "" {
t.Fatalf("Notify.Distributor.PipelineIDTemplate = %q, want empty", cfg.Notify.Distributor.PipelineIDTemplate)
}
if cfg.Notify.Distributor.BundleIDTemplate != "weatherreporter.{location_id}.{report_id}" {
t.Fatalf("Notify.Distributor.BundleIDTemplate = %q, want default", cfg.Notify.Distributor.BundleIDTemplate)
}
if cfg.Notify.Distributor.IdempotencyKeyTemplate != "{bundle_id}.{run_id}" {
t.Fatalf("Notify.Distributor.IdempotencyKeyTemplate = %q, want default", cfg.Notify.Distributor.IdempotencyKeyTemplate)
}
if !cfg.Notify.Distributor.Batch.Enabled {
t.Fatalf("Notify.Distributor.Batch.Enabled = false, want true")
}
if cfg.Notify.Distributor.Batch.PipelineIDTemplate != "weatherreporter" {
t.Fatalf("Notify.Distributor.Batch.PipelineIDTemplate = %q, want weatherreporter", cfg.Notify.Distributor.Batch.PipelineIDTemplate)
}
if cfg.Notify.Distributor.Batch.BundleIDTemplate != "weatherreporter.{location_id}.{batch}" {
t.Fatalf("Notify.Distributor.Batch.BundleIDTemplate = %q, want default", cfg.Notify.Distributor.Batch.BundleIDTemplate)
}
if cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate != "{bundle_id}.{batch_run_id}" {
t.Fatalf("Notify.Distributor.Batch.IdempotencyKeyTemplate = %q, want default", cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate)
}
if cfg.MissingSource.Default != MissingSourceWarn {
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
}
}
func TestDaypartNamesMustHaveDistinctCanonicalIdentities(t *testing.T) {
tests := []struct {
name string
names []string
wantErr string
}{
{name: "exact duplicate", names: []string{"morning", "morning"}, wantErr: "conflicts with"},
{name: "case-only duplicate", names: []string{"morning", "MORNING"}, wantErr: "conflicts with"},
{name: "punctuation-normalized duplicate", names: []string{"morning", "morning!"}, wantErr: "conflicts with"},
{name: "distinct Unicode names", names: []string{"mañana", "manana"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Defaults()
cfg.Dayparts = make([]DaypartConfig, 0, len(tt.names))
for _, name := range tt.names {
cfg.Dayparts = append(cfg.Dayparts, DaypartConfig{Name: name, Start: "06:00", End: "12:00"})
}
err := Validate(cfg)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Validate() error = %v, want %q", err, tt.wantErr)
}
})
}
}
func TestWeatherAPIBaseURLValidation(t *testing.T) {
tests := []struct {
name string
baseURL string
wantErr string
}{
{name: "local HTTP", baseURL: "http://127.0.0.1:8080/weather/"},
{name: "local HTTPS", baseURL: "https://127.0.0.1:8443/weather/"},
{name: "unsupported scheme", baseURL: "ftp://weather.example.test/", wantErr: "weather_api.base_url must use http or https"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Defaults()
cfg.WeatherAPI.BaseURL = tt.baseURL
err := Validate(cfg)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Validate() error = %v, want %q", err, tt.wantErr)
}
})
}
}
func TestOutputDirectoryLoading(t *testing.T) {
tests := []struct {
name string
yaml string
wantValue string
wantErr string
}{
{
name: "omitted",
yaml: "{}\n",
wantValue: "",
},
{
name: "explicit empty",
yaml: `
output:
directory: ""
`,
wantValue: "",
},
{
name: "absolute path",
yaml: `
output:
directory: /var/lib/weatherreporter/reports
`,
wantValue: "/var/lib/weatherreporter/reports",
},
{
name: "relative path",
yaml: `
output:
directory: reports/../published
`,
wantValue: "reports/../published",
},
{
name: "whitespace only",
yaml: `
output:
directory: " \t "
`,
wantErr: "output.directory",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg, err := LoadFile(writeConfig(t, tt.yaml))
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("LoadFile() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
if cfg.Output.Directory != tt.wantValue {
t.Fatalf("Output.Directory = %q, want %q", cfg.Output.Directory, tt.wantValue)
}
})
}
}
func TestOutputDirectoryValidationIsConsistentForLoadedAndConstructedConfigs(t *testing.T) {
tests := []struct {
name string
directory string
wantErr string
}{
{name: "empty"},
{name: "absolute path", directory: "/var/lib/weatherreporter/reports"},
{name: "relative path", directory: "reports/../published"},
{name: "whitespace only", directory: " \t ", wantErr: "output.directory"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
yaml := "output:\n directory: " + strconv.Quote(tt.directory) + "\n"
_, loadErr := LoadFile(writeConfig(t, yaml))
cfg := Defaults()
cfg.Output.Directory = tt.directory
validateErr := Validate(cfg)
if (loadErr == nil) != (validateErr == nil) {
t.Fatalf("LoadFile() error = %v, Validate() error = %v", loadErr, validateErr)
}
if tt.wantErr != "" {
if loadErr == nil || !strings.Contains(loadErr.Error(), tt.wantErr) {
t.Fatalf("LoadFile() error = %v, want %q", loadErr, tt.wantErr)
}
if validateErr == nil || !strings.Contains(validateErr.Error(), tt.wantErr) {
t.Fatalf("Validate() error = %v, want %q", validateErr, tt.wantErr)
}
}
})
}
}
func TestOutputDirectoryRejectsUnknownFields(t *testing.T) {
_, err := LoadFile(writeConfig(t, `
output:
location: reports
`))
if err == nil {
t.Fatal("LoadFile() error = nil, want strict decoding error")
}
if !strings.Contains(err.Error(), "field location not found") {
t.Fatalf("LoadFile() error = %q, want output field rejection", err.Error())
}
}
func TestLoadExampleConfig(t *testing.T) {
cfg, err := LoadFile(filepath.Join("..", "..", "examples", "config.yml"))
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
if cfg.WeatherAPI.BaseURL != "https://weather.api.example.com/" {
t.Fatalf("BaseURL = %q, want configured example URL", cfg.WeatherAPI.BaseURL)
}
if cfg.WeatherAPI.Timeout != 15*time.Second {
t.Fatalf("Timeout = %s, want 15s", cfg.WeatherAPI.Timeout)
}
if cfg.MissingSource.Sources["alerts"] != MissingSourceNone {
t.Fatalf("alerts policy = %q, want none", cfg.MissingSource.Sources["alerts"])
}
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
t.Fatalf("Location = %#v, want example location", cfg.Location)
}
if cfg.Output.Directory != "/var/lib/weatherreporter/reports" {
t.Fatalf("Output.Directory = %q, want maintained example value", cfg.Output.Directory)
}
if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{report_id}" {
t.Fatalf("PipelineIDTemplate = %q, want example pipeline template", cfg.Notify.Distributor.PipelineIDTemplate)
}
if !cfg.Notify.Distributor.Batch.Enabled {
t.Fatalf("Notify.Distributor.Batch.Enabled = false, want true")
}
if cfg.Notify.Distributor.Batch.PipelineIDTemplate != "weatherreporter" {
t.Fatalf("Batch PipelineIDTemplate = %q, want weatherreporter", cfg.Notify.Distributor.Batch.PipelineIDTemplate)
}
if cfg.Notify.Distributor.Batch.BundleIDTemplate != "weatherreporter.{location_id}.{batch}" {
t.Fatalf("Batch BundleIDTemplate = %q, want example batch bundle template", cfg.Notify.Distributor.Batch.BundleIDTemplate)
}
if cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate != "{bundle_id}.{batch_run_id}" {
t.Fatalf("Batch IdempotencyKeyTemplate = %q, want example batch idempotency template", cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate)
}
overrides, err := cfg.ReportModuleOverrides()
if err != nil {
t.Fatalf("ReportModuleOverrides() error = %v", err)
}
hourly := overrides[report.Hourly]
if len(hourly) != 9 {
t.Fatalf("hourly example override length = %d, want 9", len(hourly))
}
if hourly[0].ID != module.Metadata ||
hourly[1].ID != module.CurrentConditions ||
hourly[2].ID != module.HourlyForecast ||
hourly[3].ID != module.PrecipTiming ||
hourly[4].ID != module.AlertDigest ||
hourly[5].ID != module.SPCConvectiveOutlooks ||
hourly[6].ID != module.AreaForecastDiscussion ||
hourly[7].ID != module.SPCConvectiveDiscussion ||
hourly[8].ID != module.WeatherStory {
t.Fatalf("hourly example override = %#v, want configured module order", hourly)
}
}
func TestLoadMinimalExampleConfig(t *testing.T) {
cfg, err := LoadFile(filepath.Join("..", "..", "examples", "minimal-config.yml"))
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
if cfg.WeatherAPI.BaseURL != "https://weather.api.example.com/" {
t.Fatalf("BaseURL = %q, want example URL", cfg.WeatherAPI.BaseURL)
}
if cfg.WeatherAPI.Units != "us" {
t.Fatalf("Units = %q, want default us", cfg.WeatherAPI.Units)
}
if cfg.Promptkit.Timeout != 2*time.Minute || cfg.Promptkit.Local.ConcurrencyLimit != 1 {
t.Fatalf("Promptkit defaults = %#v", cfg.Promptkit)
}
if cfg.Location.Name != "Brentwood" {
t.Fatalf("Location.Name = %q, want default Brentwood", cfg.Location.Name)
}
}
func TestLoadRejectsRetiredExecutionConfiguration(t *testing.T) {
_, err := LoadFile(writeConfig(t, "scriptorium:\n binary: scriptorium\n"))
if err == nil || !strings.Contains(err.Error(), "migrate to promptkit") {
t.Fatalf("LoadFile() error = %v, want actionable migration error", err)
}
}
func TestLoadRejectsRemovedRecentChangeConfiguration(t *testing.T) {
_, err := LoadFile(writeConfig(t, "recent_change:\n temperature_degrees: 5\n"))
if err == nil || !strings.Contains(err.Error(), "recent_change") {
t.Fatalf("LoadFile() error = %v, want removed configuration rejection", err)
}
}
func TestLoadRejectsRemovedWorkspaceConfiguration(t *testing.T) {
_, err := LoadFile(writeConfig(t, "workspace:\n root: workspace\n"))
if err == nil || !strings.Contains(err.Error(), "workspace") {
t.Fatalf("LoadFile() error = %v, want removed configuration rejection", err)
}
}
func TestLoadReportModuleOverrides(t *testing.T) {
path := writeConfig(t, `
reports:
daily:
deterministic_modules:
- metadata
- current_conditions
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion
options:
sections:
- short_term
- spc_convective_discussion
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
overrides, err := cfg.ReportModuleOverrides()
if err != nil {
t.Fatalf("ReportModuleOverrides() error = %v", err)
}
items := overrides[report.Daily]
if len(items) != 6 {
t.Fatalf("daily override length = %d, want 6", len(items))
}
if items[0].ID != module.Metadata ||
items[1].ID != module.CurrentConditions ||
items[2].ID != module.AlertDigest ||
items[3].ID != module.SPCConvectiveOutlooks ||
items[4].ID != module.AreaForecastDiscussion ||
items[5].ID != module.SPCConvectiveDiscussion {
t.Fatalf("daily override = %#v, want configured module order", items)
}
options, ok := items[4].Options.(module.AreaForecastDiscussionOptions)
if !ok {
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", items[4].Options)
}
if strings.Join(options.Sections, ",") != "short_term" {
t.Fatalf("AFD sections = %#v, want short_term", options.Sections)
}
}
func TestLoadTodayReportModuleOverrides(t *testing.T) {
path := writeConfig(t, `
reports:
today:
deterministic_modules:
- metadata
- current_conditions
- derived_daily_summary
- derived_daypart_summaries
- today_planning
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
overrides, err := cfg.ReportModuleOverrides()
if err != nil {
t.Fatalf("ReportModuleOverrides() error = %v", err)
}
items := overrides[report.Today]
if len(items) != 5 {
t.Fatalf("today override length = %d, want 5", len(items))
}
want := []module.ID{
module.Metadata,
module.CurrentConditions,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.TodayPlanning,
}
for i, id := range want {
if items[i].ID != id {
t.Fatalf("today override[%d] = %s, want %s", i, items[i].ID, id)
}
}
if _, ok := overrides[report.Daily]; ok {
t.Fatalf("daily override = %#v, want today override to stay distinct", overrides[report.Daily])
}
}
func TestLoadHourlyReportModuleOverrides(t *testing.T) {
path := writeConfig(t, `
reports:
hourly:
deterministic_modules:
- metadata
- hourly_forecast
- precip_timing
- alert_digest
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
overrides, err := cfg.ReportModuleOverrides()
if err != nil {
t.Fatalf("ReportModuleOverrides() error = %v", err)
}
items := overrides[report.Hourly]
if len(items) != 4 {
t.Fatalf("hourly override length = %d, want 4", len(items))
}
if items[0].ID != module.Metadata ||
items[1].ID != module.HourlyForecast ||
items[2].ID != module.PrecipTiming ||
items[3].ID != module.AlertDigest {
t.Fatalf("hourly override = %#v, want configured module order", items)
}
}
func TestLoadReportDistributorPathOverrides(t *testing.T) {
path := writeConfig(t, `
reports:
daily:
distributor:
path_templates:
- "daily/{valid_start_date}/{run_id}.md"
- "daily/{valid_start_date}/index.md"
today:
deterministic_modules:
- metadata
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
daily := cfg.Reports["daily"].Distributor
if !daily.PathTemplatesSet() {
t.Fatal("daily distributor path_templates set = false, want true")
}
want := []string{
"daily/{valid_start_date}/{run_id}.md",
"daily/{valid_start_date}/index.md",
}
if !reflect.DeepEqual(daily.PathTemplates, want) {
t.Fatalf("daily path templates = %#v, want %#v", daily.PathTemplates, want)
}
if cfg.Reports["today"].Distributor.PathTemplatesSet() {
t.Fatal("today distributor path_templates set = true, want false")
}
overrides, err := cfg.ReportDistributorPathOverrides()
if err != nil {
t.Fatalf("ReportDistributorPathOverrides() error = %v", err)
}
if !reflect.DeepEqual(overrides[report.Daily], want) {
t.Fatalf("daily distributor override = %#v, want %#v", overrides[report.Daily], want)
}
if _, ok := overrides[report.Today]; ok {
t.Fatalf("today distributor override = %#v, want omitted override absent", overrides[report.Today])
}
}
func TestReportDistributorPathTemplatesSetTracksExplicitEmptyList(t *testing.T) {
var cfg Config
if err := yaml.Unmarshal([]byte(`
reports:
daily:
distributor:
path_templates: []
today:
distributor: {}
`), &cfg); err != nil {
t.Fatalf("yaml.Unmarshal() error = %v", err)
}
if !cfg.Reports["daily"].Distributor.PathTemplatesSet() {
t.Fatal("daily distributor path_templates set = false, want true")
}
if len(cfg.Reports["daily"].Distributor.PathTemplates) != 0 {
t.Fatalf("daily path templates = %#v, want empty explicit list", cfg.Reports["daily"].Distributor.PathTemplates)
}
if cfg.Reports["today"].Distributor.PathTemplatesSet() {
t.Fatal("today distributor path_templates set = true, want false")
}
}
func TestValidateReportModuleKeysWithoutMutatingOptions(t *testing.T) {
cfg := Defaults()
rawOptions := map[string]any{
"sections": []any{"short_term"},
}
cfg.Reports = map[string]ReportConfig{
"daily": {
DeterministicModules: []ModuleConfigItem{
{ID: module.Metadata},
{ID: module.AreaForecastDiscussion, Options: rawOptions},
},
deterministicModulesSet: true,
},
}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
got, ok := cfg.Reports["daily"].DeterministicModules[1].Options.(map[string]any)
if !ok || !reflect.DeepEqual(got, rawOptions) {
t.Fatalf("Options after Validate = %#v, want original raw map", cfg.Reports["daily"].DeterministicModules[1].Options)
}
}
func TestReportModuleOverridesNormalizesConstructedOptionsWithoutMutatingConfig(t *testing.T) {
cfg := Defaults()
rawOptions := map[string]any{
"sections": []any{"short_term"},
}
cfg.Reports = map[string]ReportConfig{
"daily": {
DeterministicModules: []ModuleConfigItem{
{ID: module.Metadata},
{ID: module.AreaForecastDiscussion, Options: rawOptions},
},
deterministicModulesSet: true,
},
}
overrides, err := cfg.ReportModuleOverrides()
if err != nil {
t.Fatalf("ReportModuleOverrides() error = %v", err)
}
options, ok := overrides[report.Daily][1].Options.(module.AreaForecastDiscussionOptions)
if !ok {
t.Fatalf("override options type = %T, want AreaForecastDiscussionOptions", overrides[report.Daily][1].Options)
}
if strings.Join(options.Sections, ",") != "short_term" {
t.Fatalf("override sections = %#v, want short_term", options.Sections)
}
if got, ok := cfg.Reports["daily"].DeterministicModules[1].Options.(map[string]any); !ok || !reflect.DeepEqual(got, rawOptions) {
t.Fatalf("config options after ReportModuleOverrides = %#v, want original raw map", cfg.Reports["daily"].DeterministicModules[1].Options)
}
}
func TestReportModuleOverridesCanonicalizesTypedPointerOptions(t *testing.T) {
cfg := Defaults()
options := &module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}
cfg.Reports = map[string]ReportConfig{
"daily": {
DeterministicModules: []ModuleConfigItem{
{ID: module.Metadata},
{ID: module.AreaForecastDiscussion, Options: options},
},
deterministicModulesSet: true,
},
}
overrides, err := cfg.ReportModuleOverrides()
if err != nil {
t.Fatalf("ReportModuleOverrides() error = %v", err)
}
got, ok := overrides[report.Daily][1].Options.(module.AreaForecastDiscussionOptions)
if !ok {
t.Fatalf("override options type = %T, want AreaForecastDiscussionOptions", overrides[report.Daily][1].Options)
}
if !reflect.DeepEqual(got, *options) {
t.Fatalf("override options = %#v, want %#v", got, *options)
}
if cfg.Reports["daily"].DeterministicModules[1].Options != options {
t.Fatalf("config options = %#v, want original pointer %#v", cfg.Reports["daily"].DeterministicModules[1].Options, options)
}
}
func TestValidateReportModuleAliasesDirectly(t *testing.T) {
retiredDailyKey := retiredDailyReportKeyForTest()
tests := []struct {
name string
reports map[string]ReportConfig
wantErr string
}{
{
name: "RetiredDailyReportID",
reports: map[string]ReportConfig{
"daily": {},
retiredDailyKey: {},
},
wantErr: "reports." + retiredDailyKey,
},
{
name: "TodayAndDailyAreDistinct",
reports: map[string]ReportConfig{
"daily": {},
"today": {},
},
},
{
name: "UnknownReport",
reports: map[string]ReportConfig{
"moon": {},
},
wantErr: "reports.moon",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Defaults()
cfg.Reports = tt.reports
err := Validate(cfg)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
return
}
if err == nil {
t.Fatal("Validate() error = nil, want report key error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Validate() error = %q, want %q", err.Error(), tt.wantErr)
}
})
}
}
func TestReportModuleOverridesRejectsInvalidReportKeys(t *testing.T) {
cfg := Defaults()
cfg.Reports = map[string]ReportConfig{
"moon": {
DeterministicModules: []ModuleConfigItem{{ID: module.Metadata}},
deterministicModulesSet: true,
},
}
_, err := cfg.ReportModuleOverrides()
if err == nil {
t.Fatal("ReportModuleOverrides() error = nil, want invalid report key")
}
if !strings.Contains(err.Error(), "reports.moon") {
t.Fatalf("ReportModuleOverrides() error = %q, want report key context", err.Error())
}
}
func TestReportModuleOverrideValidation(t *testing.T) {
retiredDailyKey := retiredDailyReportKeyForTest()
tests := []struct {
name string
yaml string
wantErr string
}{
{
name: "UnknownReport",
yaml: `
reports:
moon:
deterministic_modules:
- metadata
`,
wantErr: "reports.moon",
},
{
name: "UnknownModule",
yaml: `
reports:
daily:
deterministic_modules:
- missing_module
`,
wantErr: `unknown module "missing_module"`,
},
{
name: "DuplicateModule",
yaml: `
reports:
daily:
deterministic_modules:
- metadata
- metadata
`,
wantErr: `duplicate module "metadata"`,
},
{
name: "IncompatibleModule",
yaml: `
reports:
daily:
deterministic_modules:
- tomorrow_planning
`,
wantErr: `not compatible with report "daily"`,
},
{
name: "RemovedPlaceholderModule",
yaml: `
reports:
daily:
deterministic_modules:
- forecast_delta
`,
wantErr: `unknown module "forecast_delta"`,
},
{
name: "RetiredTomorrowReportID",
yaml: `
reports:
daily_tomorrow:
deterministic_modules:
- metadata
`,
wantErr: "reports.daily_tomorrow",
},
{
name: "InvalidOptions",
yaml: `
reports:
daily:
deterministic_modules:
- id: metadata
options:
sections:
- short_term
`,
wantErr: "options are invalid",
},
{
name: "RetiredDailyReportID",
yaml: `
reports:
daily:
deterministic_modules:
- metadata
` + retiredDailyKey + `:
deterministic_modules:
- current_conditions
`,
wantErr: "reports." + retiredDailyKey,
},
{
name: "HourlyIncompatibleDailyModule",
yaml: `
reports:
hourly:
deterministic_modules:
- derived_daily_summary
`,
wantErr: `not compatible with report "hourly"`,
},
{
name: "UnknownReportField",
yaml: `
reports:
daily:
modules:
- metadata
`,
wantErr: `unknown report entry field "modules"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := LoadFile(writeConfig(t, tt.yaml))
if err == nil {
t.Fatal("LoadFile() error = nil, want validation error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
}
})
}
}
func TestReportDistributorPathOverrideValidation(t *testing.T) {
tests := []struct {
name string
yaml string
wantErr string
}{
{
name: "UnknownReportField",
yaml: `
reports:
daily:
distributor_paths:
- latest.md
`,
wantErr: `unknown report entry field "distributor_paths"`,
},
{
name: "UnknownDistributorField",
yaml: `
reports:
daily:
distributor:
paths:
- latest.md
`,
wantErr: `unknown report distributor field "paths"`,
},
{
name: "UnknownTemplateVariable",
yaml: `
reports:
daily:
distributor:
path_templates:
- "{unknown}.md"
`,
wantErr: `reports.daily.distributor.path_templates[0] contains unknown template variable "unknown"`,
},
{
name: "AbsolutePath",
yaml: `
reports:
daily:
distributor:
path_templates:
- "/daily.md"
`,
wantErr: "reports.daily.distributor.path_templates[0] must render a relative path",
},
{
name: "ParentSegment",
yaml: `
reports:
daily:
distributor:
path_templates:
- "daily/../index.md"
`,
wantErr: "reports.daily.distributor.path_templates[0] must not render . or .. path segments",
},
{
name: "Manifest",
yaml: `
reports:
daily:
distributor:
path_templates:
- "daily/manifest.json"
`,
wantErr: `reports.daily.distributor.path_templates[0] must not render reserved path segment "manifest.json"`,
},
{
name: "DuplicateRenderedPath",
yaml: `
reports:
daily:
distributor:
path_templates:
- "daily/index.md"
- "daily/index.md"
`,
wantErr: `reports.daily.distributor.path_templates renders duplicate path "daily/index.md"`,
},
{
name: "EmptyOverrideList",
yaml: `
reports:
daily:
distributor:
path_templates: []
`,
wantErr: "reports.daily.distributor.path_templates must contain at least one entry",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := LoadFile(writeConfig(t, tt.yaml))
if err == nil {
t.Fatal("LoadFile() error = nil, want validation error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
}
})
}
}
func TestReportDistributorPathOverridesConsistentForLoadedAndConstructedConfig(t *testing.T) {
yaml := `
reports:
daily:
distributor:
path_templates:
- "daily/{valid_start_date}/index.md"
`
reports := map[string]ReportConfig{
"daily": {
Distributor: ReportDistributorConfig{
PathTemplates: []string{"daily/{valid_start_date}/index.md"},
pathTemplatesSet: true,
},
},
}
cfg, err := LoadFile(writeConfig(t, yaml))
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
loaded, err := cfg.ReportDistributorPathOverrides()
if err != nil {
t.Fatalf("loaded ReportDistributorPathOverrides() error = %v", err)
}
cfg = Defaults()
cfg.Reports = reports
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
constructed, err := cfg.ReportDistributorPathOverrides()
if err != nil {
t.Fatalf("constructed ReportDistributorPathOverrides() error = %v", err)
}
if !reflect.DeepEqual(loaded, constructed) {
t.Fatalf("loaded overrides = %#v, constructed = %#v", loaded, constructed)
}
}
func TestReportModuleValidationConsistentForLoadedAndConstructedConfig(t *testing.T) {
tests := []struct {
name string
yaml string
reports map[string]ReportConfig
wantErr string
}{
{
name: "UnknownReport",
yaml: `
reports:
moon:
deterministic_modules:
- metadata
`,
reports: map[string]ReportConfig{
"moon": {
DeterministicModules: []ModuleConfigItem{{ID: module.Metadata}},
deterministicModulesSet: true,
},
},
wantErr: "reports.moon",
},
{
name: "UnknownModule",
yaml: `
reports:
daily:
deterministic_modules:
- missing_module
`,
reports: map[string]ReportConfig{
"daily": {
DeterministicModules: []ModuleConfigItem{{ID: module.ID("missing_module")}},
deterministicModulesSet: true,
},
},
wantErr: `unknown module "missing_module"`,
},
{
name: "DuplicateModule",
yaml: `
reports:
daily:
deterministic_modules:
- metadata
- metadata
`,
reports: map[string]ReportConfig{
"daily": {
DeterministicModules: []ModuleConfigItem{
{ID: module.Metadata},
{ID: module.Metadata},
},
deterministicModulesSet: true,
},
},
wantErr: `duplicate module "metadata"`,
},
{
name: "IncompatibleModule",
yaml: `
reports:
daily:
deterministic_modules:
- tomorrow_planning
`,
reports: map[string]ReportConfig{
"daily": {
DeterministicModules: []ModuleConfigItem{{ID: module.TomorrowPlanning}},
deterministicModulesSet: true,
},
},
wantErr: `not compatible with report "daily"`,
},
{
name: "InvalidOptions",
yaml: `
reports:
daily:
deterministic_modules:
- id: metadata
options:
sections:
- short_term
`,
reports: map[string]ReportConfig{
"daily": {
DeterministicModules: []ModuleConfigItem{
{
ID: module.Metadata,
Options: map[string]any{
"sections": []any{"short_term"},
},
},
},
deterministicModulesSet: true,
},
},
wantErr: "options are invalid",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, loadErr := LoadFile(writeConfig(t, tt.yaml))
assertReportModuleError(t, "LoadFile", loadErr, tt.wantErr)
cfg := Defaults()
cfg.Reports = tt.reports
assertReportModuleError(t, "Validate", Validate(cfg), tt.wantErr)
_, overrideErr := cfg.ReportModuleOverrides()
assertReportModuleError(t, "ReportModuleOverrides", overrideErr, tt.wantErr)
})
}
}
func assertReportModuleError(t *testing.T, operation string, err error, want string) {
t.Helper()
if err == nil {
t.Fatalf("%s error = nil, want %q", operation, want)
}
if !strings.Contains(err.Error(), want) {
t.Fatalf("%s error = %q, want %q", operation, err.Error(), want)
}
}
func retiredDailyReportKeyForTest() string {
return strings.Join([]string{"daily", "today"}, "_")
}
func TestReportModuleOverrideRejectsRetiredHourlyKeys(t *testing.T) {
for _, key := range []string{
strings.Join([]string{"near", "term"}, "_"),
strings.Join([]string{"near", "term"}, "-"),
} {
t.Run(key, func(t *testing.T) {
_, err := LoadFile(writeConfig(t, `
reports:
`+key+`:
deterministic_modules:
- metadata
`))
if err == nil {
t.Fatal("LoadFile() error = nil, want unknown report")
}
if !strings.Contains(err.Error(), "is not a known report") {
t.Fatalf("error = %q, want unknown report", err.Error())
}
})
}
}
func TestExplicitMissingConfigReturnsError(t *testing.T) {
_, err := LoadFile(filepath.Join(t.TempDir(), "missing.yml"))
if err == nil {
t.Fatal("LoadFile() error = nil, want missing file error")
}
if !strings.Contains(err.Error(), "read config") {
t.Fatalf("error = %q, want read config context", err.Error())
}
}
func writeConfig(t *testing.T, contents string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatalf("write config fixture: %v", err)
}
return path
}
func TestInvalidConfigProducesActionableError(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yml")
if err := os.WriteFile(path, []byte("missing_source:\n default: explode\n"), 0o600); err != nil {
t.Fatalf("write config fixture: %v", err)
}
_, err := LoadFile(path)
if err == nil {
t.Fatal("LoadFile() error = nil, want validation error")
}
if !strings.Contains(err.Error(), "missing_source.default") {
t.Fatalf("error = %q, want field path", err.Error())
}
}
func TestMissingSourcePoliciesOnlyAllowSupportedOptionalSources(t *testing.T) {
for _, source := range []string{
MissingSourceObservations,
MissingSourceCurrent,
MissingSourceNarrative,
MissingSourceAlerts,
MissingSourceDiscussion,
MissingSourceWeatherStory,
MissingSourceSPCConvectiveOutlooks,
} {
t.Run("supported_"+source, func(t *testing.T) {
cfg := Defaults()
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{source: MissingSourceNone}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
loaded, err := LoadFile(writeConfig(t, "missing_source:\n sources:\n "+source+": none\n"))
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
if loaded.MissingSource.Sources[source] != MissingSourceNone {
t.Fatalf("loaded source policy = %q, want none", loaded.MissingSource.Sources[source])
}
})
}
for _, source := range []string{"alert", "unknown", "hourly", ""} {
t.Run("unsupported_"+source, func(t *testing.T) {
cfg := Defaults()
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{source: MissingSourceWarn}
err := Validate(cfg)
if err == nil || !strings.Contains(err.Error(), "missing_source.sources") {
t.Fatalf("Validate() error = %v, want unsupported source error", err)
}
})
}
}
func TestLoadAppliesOverrides(t *testing.T) {
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30"})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.WeatherAPI.Units != "metric" {
t.Fatalf("Units = %q, want metric", cfg.WeatherAPI.Units)
}
if cfg.WeatherAPI.Timezone != "+09:30" {
t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone)
}
}
func TestDisabledDistributorNotifyAcceptsOmittedFields(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yml")
if err := os.WriteFile(path, []byte("notify:\n distributor:\n enabled: false\n"), 0o600); err != nil {
t.Fatalf("write config fixture: %v", err)
}
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
if cfg.Notify.Distributor.Enabled {
t.Fatalf("Notify.Distributor.Enabled = true, want false")
}
}
func TestDistributorNotifyRejectsRemovedGlobalReportPaths(t *testing.T) {
removedField := "report_path" + "_templates"
_, err := LoadFile(writeConfig(t, `
notify:
distributor:
`+removedField+`:
- index.md
`))
if err == nil {
t.Fatal("LoadFile() error = nil, want removed global path field error")
}
if !strings.Contains(err.Error(), `unknown notify distributor field "`+removedField+`"`) {
t.Fatalf("error = %q, want removed global path field rejection", err.Error())
}
}
func TestDistributorNotifyRejectsUnknownFields(t *testing.T) {
_, err := LoadFile(writeConfig(t, `
notify:
distributor:
paths:
- index.md
`))
if err == nil {
t.Fatal("LoadFile() error = nil, want unknown distributor field error")
}
if !strings.Contains(err.Error(), `unknown notify distributor field "paths"`) {
t.Fatalf("error = %q, want unknown field rejection", err.Error())
}
}
func TestDistributorBatchNotifyRejectsUnknownFields(t *testing.T) {
_, err := LoadFile(writeConfig(t, `
notify:
distributor:
batch:
paths:
- index.md
`))
if err == nil {
t.Fatal("LoadFile() error = nil, want unknown distributor batch field error")
}
if !strings.Contains(err.Error(), `unknown notify distributor batch field "paths"`) {
t.Fatalf("error = %q, want unknown batch field rejection", err.Error())
}
}
func TestDistributorBatchNotifyPartialConfigPreservesDefaults(t *testing.T) {
cfg, err := LoadFile(writeConfig(t, `
notify:
distributor:
batch:
enabled: false
`))
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
if cfg.Notify.Distributor.Batch.Enabled {
t.Fatalf("Batch.Enabled = true, want false")
}
if cfg.Notify.Distributor.Batch.PipelineIDTemplate != "weatherreporter" {
t.Fatalf("Batch.PipelineIDTemplate = %q, want default", cfg.Notify.Distributor.Batch.PipelineIDTemplate)
}
if cfg.Notify.Distributor.Batch.BundleIDTemplate != "weatherreporter.{location_id}.{batch}" {
t.Fatalf("Batch.BundleIDTemplate = %q, want default", cfg.Notify.Distributor.Batch.BundleIDTemplate)
}
if cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate != "{bundle_id}.{batch_run_id}" {
t.Fatalf("Batch.IdempotencyKeyTemplate = %q, want default", cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate)
}
}
func TestDisabledDistributorNotifyAcceptsMalformedBatchTemplates(t *testing.T) {
cfg := Defaults()
cfg.Notify.Distributor.Enabled = false
cfg.Notify.Distributor.Batch.PipelineIDTemplate = "{unknown}"
cfg.Notify.Distributor.Batch.BundleIDTemplate = "{unknown}"
cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = "{unknown}"
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
func TestEnabledDistributorNotifyValidation(t *testing.T) {
tests := []struct {
name string
mutate func(*Config)
wantErr string
wantAbsent string
}{
{
name: "Endpoint",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Endpoint = "distributor.example.com"
},
wantErr: "notify.distributor.endpoint",
},
{
name: "UnsupportedEndpointScheme",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Endpoint = "ftp://distributor.example.test"
},
wantErr: "notify.distributor.endpoint",
},
{
name: "EndpointUserinfo",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Endpoint = "https://userinfo-secret@distributor.example.test"
},
wantErr: "notify.distributor.endpoint",
wantAbsent: "userinfo-secret",
},
{
name: "EndpointQuery",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Endpoint = "https://distributor.example.test?preview=1"
},
wantErr: "notify.distributor.endpoint",
},
{
name: "EndpointFragment",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Endpoint = "https://distributor.example.test#status"
},
wantErr: "notify.distributor.endpoint",
},
{
name: "TokenEnvEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.TokenEnv = ""
},
wantErr: "notify.distributor.token_env",
},
{
name: "TokenEnvInvalid",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.TokenEnv = "1TOKEN"
},
wantErr: "notify.distributor.token_env",
},
{
name: "Timeout",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Timeout = 0
},
wantErr: "notify.distributor.timeout",
},
{
name: "FailurePolicy",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.FailurePolicy = "warn"
},
wantErr: "notify.distributor.failure_policy",
},
{
name: "PipelineTemplateEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.PipelineIDTemplate = ""
},
wantErr: "notify.distributor.pipeline_id_template",
},
{
name: "PipelineTemplateUnknown",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.PipelineIDTemplate = "{unknown}"
},
wantErr: "notify.distributor.pipeline_id_template",
},
{
name: "PipelineTemplateRenderedEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.PipelineIDTemplate = " "
},
wantErr: "notify.distributor.pipeline_id_template",
},
{
name: "BundleTemplate",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.BundleIDTemplate = "{unknown}"
},
wantErr: "notify.distributor.bundle_id_template",
},
{
name: "BundleTemplateRenderedEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.BundleIDTemplate = " "
},
wantErr: "notify.distributor.bundle_id_template",
},
{
name: "IdempotencyTemplate",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.IdempotencyKeyTemplate = "{unknown}"
},
wantErr: "notify.distributor.idempotency_key_template",
},
{
name: "IdempotencyTemplateRenderedEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.IdempotencyKeyTemplate = " "
},
wantErr: "notify.distributor.idempotency_key_template",
},
{
name: "BatchTemplate",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.BundleIDTemplate = "{run_id}"
},
wantErr: "notify.distributor.batch.bundle_id_template",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Defaults()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
tt.mutate(&cfg)
err := Validate(cfg)
if err == nil {
t.Fatal("Validate() error = nil, want error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
}
if tt.wantAbsent != "" && strings.Contains(err.Error(), tt.wantAbsent) {
t.Fatalf("error = %q, must not contain endpoint userinfo", err.Error())
}
})
}
}
func TestEnabledDistributorNotifyAcceptsHTTPBasePaths(t *testing.T) {
for _, endpoint := range []string{
"http://distributor.example.test/archive",
"https://distributor.example.test/archive/",
} {
t.Run(endpoint, func(t *testing.T) {
cfg := Defaults()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.Endpoint = endpoint
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
})
}
}
func TestEnabledDistributorBatchNotifyValidation(t *testing.T) {
tests := []struct {
name string
mutate func(*Config)
wantErr string
}{
{
name: "PipelineTemplateEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.PipelineIDTemplate = ""
},
wantErr: "notify.distributor.batch.pipeline_id_template",
},
{
name: "PipelineTemplateUnknown",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.PipelineIDTemplate = "{report_id}"
},
wantErr: "notify.distributor.batch.pipeline_id_template",
},
{
name: "PipelineTemplateRenderedEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.PipelineIDTemplate = " "
},
wantErr: "notify.distributor.batch.pipeline_id_template",
},
{
name: "BundleTemplateEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.BundleIDTemplate = ""
},
wantErr: "notify.distributor.batch.bundle_id_template",
},
{
name: "BundleTemplateUnknown",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.BundleIDTemplate = "{run_id}"
},
wantErr: "notify.distributor.batch.bundle_id_template",
},
{
name: "BundleTemplateRenderedEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.BundleIDTemplate = " "
},
wantErr: "notify.distributor.batch.bundle_id_template",
},
{
name: "IdempotencyTemplateEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = ""
},
wantErr: "notify.distributor.batch.idempotency_key_template",
},
{
name: "IdempotencyTemplateUnknown",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = "{report_id}"
},
wantErr: "notify.distributor.batch.idempotency_key_template",
},
{
name: "IdempotencyTemplateRenderedEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = " "
},
wantErr: "notify.distributor.batch.idempotency_key_template",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Defaults()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
tt.mutate(&cfg)
err := Validate(cfg)
if err == nil {
t.Fatal("Validate() error = nil, want error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
}
})
}
}
func TestDisabledDistributorBatchNotifySkipsBatchTemplateValidation(t *testing.T) {
cfg := Defaults()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
cfg.Notify.Distributor.Batch.Enabled = false
cfg.Notify.Distributor.Batch.PipelineIDTemplate = "{unknown}"
cfg.Notify.Distributor.Batch.BundleIDTemplate = "{unknown}"
cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = "{unknown}"
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
func TestDistributorTemplateRendering(t *testing.T) {
values := DistributorTemplateValues{
LocationID: "home",
ReportID: "daily",
RunID: "20260607T120000Z",
ArtifactGroup: "daily",
BatchOutputName: "daily.md",
ValidStartDate: "2026-06-07",
ValidEndDate: "2026-06-08",
ValidStartTime: "1800",
ValidEndTime: "0600",
ValidStartStamp: "2026-06-07T1800",
ValidEndStamp: "2026-06-08T0600",
BundleID: "weatherreporter.home.daily",
}
bundleID, err := RenderDistributorBundleID("weatherreporter.{location_id}.{report_id}.{valid_start_date}", values)
if err != nil {
t.Fatalf("RenderDistributorBundleID() error = %v", err)
}
if bundleID != "weatherreporter.home.daily.2026-06-07" {
t.Fatalf("bundleID = %q, want rendered value", bundleID)
}
values.BundleID = bundleID
pipelineID, err := RenderDistributorPipelineID("weatherreporter.{artifact_group}.{valid_start_stamp}.{bundle_id}", values)
if err != nil {
t.Fatalf("RenderDistributorPipelineID() error = %v", err)
}
if pipelineID != "weatherreporter.daily.2026-06-07T1800.weatherreporter.home.daily.2026-06-07" {
t.Fatalf("pipelineID = %q, want rendered pipeline ID", pipelineID)
}
idempotencyKey, err := RenderDistributorIdempotencyKey("{bundle_id}.{valid_end_stamp}.{run_id}", values)
if err != nil {
t.Fatalf("RenderDistributorIdempotencyKey() error = %v", err)
}
if idempotencyKey != "weatherreporter.home.daily.2026-06-07.2026-06-08T0600.20260607T120000Z" {
t.Fatalf("idempotencyKey = %q, want rendered run key", idempotencyKey)
}
reportPaths, err := RenderDistributorReportPaths("reports.daily.distributor.path_templates", []string{
"{valid_start_date}/{artifact_group}/{valid_start_stamp}-{valid_end_stamp}-{run_id}.md",
"daily/{valid_start_date}/index.md",
"{valid_start_date}/{artifact_group}/latest.md",
}, values)
if err != nil {
t.Fatalf("RenderDistributorReportPaths() error = %v", err)
}
wantPaths := []string{
"2026-06-07/daily/2026-06-07T1800-2026-06-08T0600-20260607T120000Z.md",
"daily/2026-06-07/index.md",
"2026-06-07/daily/latest.md",
}
if strings.Join(reportPaths, "\n") != strings.Join(wantPaths, "\n") {
t.Fatalf("reportPaths = %#v, want %#v", reportPaths, wantPaths)
}
}
func TestDistributorReportPathRenderingUsesCallerName(t *testing.T) {
values := DistributorTemplateValues{
BatchOutputName: "report.md",
}
tests := []struct {
name string
templates []string
wantErr string
}{
{
name: "UnknownVariable",
templates: []string{"{unknown}.md"},
wantErr: `report.daily.distributor_path_templates[0] contains unknown template variable "unknown"`,
},
{
name: "InvalidPath",
templates: []string{"/{batch_output_name}"},
wantErr: "report.daily.distributor_path_templates[0] must render a relative path",
},
{
name: "DuplicatePath",
templates: []string{"latest.md", "latest.md"},
wantErr: `report.daily.distributor_path_templates renders duplicate path "latest.md"`,
},
{
name: "Empty",
templates: nil,
wantErr: "report.daily.distributor_path_templates must contain at least one entry",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := RenderDistributorReportPaths("report.daily.distributor_path_templates", tt.templates, values)
if err == nil {
t.Fatal("RenderDistributorReportPaths() error = nil, want error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
}
})
}
}
func TestDistributorBatchTemplateRendering(t *testing.T) {
values := DistributorBatchTemplateValues{
LocationID: "home",
Batch: "evening",
BatchRunID: "20260617T235037.642224552Z_evening",
BatchStartedDate: "2026-06-17",
}
bundleID, err := RenderDistributorBatchBundleID("weatherreporter.{location_id}.{batch}", values)
if err != nil {
t.Fatalf("RenderDistributorBatchBundleID() error = %v", err)
}
if bundleID != "weatherreporter.home.evening" {
t.Fatalf("bundleID = %q, want batch bundle ID", bundleID)
}
values.BundleID = bundleID
pipelineID, err := RenderDistributorBatchPipelineID("weatherreporter", values)
if err != nil {
t.Fatalf("RenderDistributorBatchPipelineID() error = %v", err)
}
if pipelineID != "weatherreporter" {
t.Fatalf("pipelineID = %q, want weatherreporter", pipelineID)
}
idempotencyKey, err := RenderDistributorBatchIdempotencyKey("{bundle_id}.{batch_run_id}", values)
if err != nil {
t.Fatalf("RenderDistributorBatchIdempotencyKey() error = %v", err)
}
if idempotencyKey != "weatherreporter.home.evening.20260617T235037.642224552Z_evening" {
t.Fatalf("idempotencyKey = %q, want batch retry key", idempotencyKey)
}
bundleID, err = RenderDistributorBatchBundleID("weatherreporter.{batch_started_date}.{batch}", values)
if err != nil {
t.Fatalf("RenderDistributorBatchBundleID() with date error = %v", err)
}
if bundleID != "weatherreporter.2026-06-17.evening" {
t.Fatalf("bundleID = %q, want date-aware batch bundle ID", bundleID)
}
}
func TestDistributorTemplateRejectsUnknownVariables(t *testing.T) {
tests := []struct {
name string
template string
render func(string) error
}{
{
name: "SingleReport",
template: "{unknown}",
render: func(template string) error {
_, err := RenderDistributorBundleID(template, DistributorTemplateValues{})
return err
},
},
{
name: "Batch",
template: "{report_id}",
render: func(template string) error {
_, err := RenderDistributorBatchBundleID(template, DistributorBatchTemplateValues{})
return err
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.render(tt.template)
if err == nil {
t.Fatal("rendering error = nil, want error")
}
})
}
}
func TestDistributorTemplateParserRejectsMalformedVariables(t *testing.T) {
const name = "notify.distributor.bundle_id_template"
tests := []struct {
name string
template string
wantErr string
}{
{name: "Unclosed", template: "{location_id", wantErr: name + " contains an unclosed template variable"},
{name: "Unopened", template: "location_id}", wantErr: name + " contains an unopened template variable"},
{name: "Empty", template: "{}", wantErr: name + " contains an empty template variable"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := renderDistributorTemplate(name, tt.template, func(variable string) (string, bool) {
return "value", variable == "location_id"
})
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("error = %v, want %q", err, tt.wantErr)
}
})
}
}
func TestDistributorReportPathValidation(t *testing.T) {
tests := []struct {
name string
path string
ok bool
}{
{name: "Simple", path: "daily.md", ok: true},
{name: "Nested", path: "reports/daily.md", ok: true},
{name: "Empty", path: "", ok: false},
{name: "Absolute", path: "/reports/daily.md", ok: false},
{name: "WindowsAbsolute", path: "C:/reports/daily.md", ok: false},
{name: "Backslash", path: `reports\daily.md`, ok: false},
{name: "CurrentSegment", path: "reports/./daily.md", ok: false},
{name: "ParentSegment", path: "reports/../daily.md", ok: false},
{name: "EmptySegment", path: "reports//daily.md", ok: false},
{name: "Manifest", path: "reports/manifest.json", ok: false},
{name: "DistributorMetadata", path: "reports/" + distributorSidecarBasename(), ok: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateDistributorReportPath("test.path", tt.path)
if tt.ok && err != nil {
t.Fatalf("ValidateDistributorReportPath() error = %v", err)
}
if !tt.ok && err == nil {
t.Fatal("ValidateDistributorReportPath() error = nil, want error")
}
})
}
}
func TestDistributorReportPathRenderingRejectsInvalidValues(t *testing.T) {
tests := []struct {
name string
batchOutputName string
}{
{name: "Absolute", batchOutputName: "/daily.md"},
{name: "Backslash", batchOutputName: `reports\daily.md`},
{name: "CurrentSegment", batchOutputName: "./daily.md"},
{name: "ParentSegment", batchOutputName: "../daily.md"},
{name: "EmptySegment", batchOutputName: "reports//daily.md"},
{name: "Manifest", batchOutputName: "manifest.json"},
{name: "DistributorMetadata", batchOutputName: distributorSidecarBasename()},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := RenderDistributorReportPaths("report.daily.distributor_path_templates", []string{"{batch_output_name}"}, DistributorTemplateValues{
BatchOutputName: tt.batchOutputName,
})
if err == nil {
t.Fatal("RenderDistributorReportPaths() error = nil, want error")
}
if !strings.Contains(err.Error(), "report.daily.distributor_path_templates[0]") {
t.Fatalf("error = %q, want caller path name", err.Error())
}
})
}
}
func TestLoadFileLoadsSecretsBeforeReturningNotifyConfig(t *testing.T) {
dir := t.TempDir()
secretsDir := filepath.Join(dir, "secrets")
if err := os.Mkdir(secretsDir, 0o700); err != nil {
t.Fatalf("create secrets directory: %v", err)
}
if err := os.WriteFile(filepath.Join(secretsDir, "DISTRIBUTOR_UPLOAD_TOKEN"), []byte("loaded-token"), 0o600); err != nil {
t.Fatalf("write secret: %v", err)
}
path := filepath.Join(dir, "config.yml")
configYAML := "secrets:\n" +
" directory: " + secretsDir + "\n" +
"notify:\n" +
" distributor:\n" +
" enabled: true\n" +
" pipeline_id_template: weatherreporter.{report_id}\n"
if err := os.WriteFile(path, []byte(configYAML), 0o600); err != nil {
t.Fatalf("write config fixture: %v", err)
}
t.Setenv("DISTRIBUTOR_UPLOAD_TOKEN", "")
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
if cfg.Notify.Distributor.TokenEnv != "DISTRIBUTOR_UPLOAD_TOKEN" {
t.Fatalf("TokenEnv = %q, want DISTRIBUTOR_UPLOAD_TOKEN", cfg.Notify.Distributor.TokenEnv)
}
if got := os.Getenv(cfg.Notify.Distributor.TokenEnv); got != "loaded-token" {
t.Fatalf("environment value = %q, want loaded-token", got)
}
}
func TestLoadSecretsDisabledLeavesEnvironmentUnchanged(t *testing.T) {
t.Setenv("WEATHERREPORTER_DISABLED_SECRET", "original")
secrets, err := loadSecrets(SecretsConfig{})
if err != nil {
t.Fatalf("loadSecrets() error = %v", err)
}
if len(secrets) != 0 {
t.Fatalf("staged secrets = %#v, want none", secrets)
}
if got := os.Getenv("WEATHERREPORTER_DISABLED_SECRET"); got != "original" {
t.Fatalf("environment value = %q, want original", got)
}
}
func TestLoadFileLoadsSecretsDirectory(t *testing.T) {
dir := t.TempDir()
secretsDir := filepath.Join(dir, "secrets")
if err := os.Mkdir(secretsDir, 0o700); err != nil {
t.Fatalf("create secrets directory: %v", err)
}
if err := os.WriteFile(filepath.Join(secretsDir, "WEATHERREPORTER_SECRET"), []byte("from-file"), 0o600); err != nil {
t.Fatalf("write secret: %v", err)
}
path := filepath.Join(dir, "config.yml")
if err := os.WriteFile(path, []byte("secrets:\n directory: "+secretsDir+"\n"), 0o600); err != nil {
t.Fatalf("write config fixture: %v", err)
}
t.Setenv("WEATHERREPORTER_SECRET", "")
if _, err := LoadFile(path); err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
if got := os.Getenv("WEATHERREPORTER_SECRET"); got != "from-file" {
t.Fatalf("environment value = %q, want from-file", got)
}
}
func TestLoadSecretsOverwritesExistingEnvironment(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "WEATHERREPORTER_SECRET"), []byte("from-file"), 0o600); err != nil {
t.Fatalf("write secret: %v", err)
}
t.Setenv("WEATHERREPORTER_SECRET", "existing")
secrets, err := loadSecrets(SecretsConfig{Directory: dir})
if err != nil {
t.Fatalf("loadSecrets() error = %v", err)
}
if err := applySecrets(secrets); err != nil {
t.Fatalf("applySecrets() error = %v", err)
}
if got := os.Getenv("WEATHERREPORTER_SECRET"); got != "from-file" {
t.Fatalf("environment value = %q, want from-file", got)
}
}
func TestLoadSecretsTrimsOneTrailingLineEnding(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "LF", input: "value\n", want: "value"},
{name: "CRLF", input: "value\r\n", want: "value"},
{name: "TwoLF", input: "value\n\n", want: "value\n"},
{name: "LoneCR", input: "value\r", want: "value\r"},
{name: "NoNewline", input: "value", want: "value"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "WEATHERREPORTER_SECRET"), []byte(tt.input), 0o600); err != nil {
t.Fatalf("write secret: %v", err)
}
t.Setenv("WEATHERREPORTER_SECRET", "")
secrets, err := loadSecrets(SecretsConfig{Directory: dir})
if err != nil {
t.Fatalf("loadSecrets() error = %v", err)
}
if err := applySecrets(secrets); err != nil {
t.Fatalf("applySecrets() error = %v", err)
}
if got := os.Getenv("WEATHERREPORTER_SECRET"); got != tt.want {
t.Fatalf("environment value = %q, want %q", got, tt.want)
}
})
}
}
func TestLoadSecretsRejectsInvalidDirectoryEntries(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, dir string)
wantErr string
}{
{
name: "InvalidFilename",
setup: func(t *testing.T, dir string) {
if err := os.WriteFile(filepath.Join(dir, "1INVALID"), []byte("secret-value"), 0o600); err != nil {
t.Fatalf("write invalid secret: %v", err)
}
},
wantErr: "invalid environment variable name",
},
{
name: "Subdirectory",
setup: func(t *testing.T, dir string) {
if err := os.Mkdir(filepath.Join(dir, "SUBDIR"), 0o700); err != nil {
t.Fatalf("create subdirectory: %v", err)
}
},
wantErr: "not a directory",
},
{
name: "Symlink",
setup: func(t *testing.T, dir string) {
target := filepath.Join(dir, "TARGET")
if err := os.WriteFile(target, []byte("secret-value"), 0o600); err != nil {
t.Fatalf("write target: %v", err)
}
testutil.RequireSymlink(t, target, filepath.Join(dir, "SYMLINK"))
},
wantErr: "not a symlink",
},
{
name: "Unreadable",
setup: func(t *testing.T, dir string) {
path := filepath.Join(dir, "UNREADABLE")
if err := os.WriteFile(path, []byte("secret-value"), 0o600); err != nil {
t.Fatalf("write unreadable secret: %v", err)
}
if err := os.Chmod(path, 0o000); err != nil {
t.Fatalf("chmod unreadable secret: %v", err)
}
t.Cleanup(func() {
_ = os.Chmod(path, 0o600)
})
if _, err := os.ReadFile(path); err == nil {
t.Skip("test process can read files without permission bits")
}
},
wantErr: "read secret file",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
tt.setup(t, dir)
_, err := loadSecrets(SecretsConfig{Directory: dir})
if err == nil {
t.Fatal("loadSecrets() error = nil, want error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
}
if strings.Contains(err.Error(), "secret-value") {
t.Fatalf("error = %q, want no secret value", err.Error())
}
})
}
}
func TestLoadSecretsRejectsMissingDirectory(t *testing.T) {
_, err := loadSecrets(SecretsConfig{Directory: filepath.Join(t.TempDir(), "missing")})
if err == nil {
t.Fatal("loadSecrets() error = nil, want missing directory error")
}
if !strings.Contains(err.Error(), "read secrets directory") {
t.Fatalf("error = %q, want read secrets directory context", err.Error())
}
}
func TestLoadFileSecretDirectoryFailureLeavesEnvironmentUnchanged(t *testing.T) {
dir := t.TempDir()
secretsDir := filepath.Join(dir, "secrets")
if err := os.Mkdir(secretsDir, 0o700); err != nil {
t.Fatalf("create secrets directory: %v", err)
}
if err := os.WriteFile(filepath.Join(secretsDir, "A_SECRET"), []byte("new-value"), 0o600); err != nil {
t.Fatalf("write secret: %v", err)
}
if err := os.WriteFile(filepath.Join(secretsDir, "Z-INVALID"), []byte("unused"), 0o600); err != nil {
t.Fatalf("write invalid secret: %v", err)
}
path := writeConfig(t, "secrets:\n directory: "+secretsDir+"\n")
t.Setenv("A_SECRET", "original-value")
_, err := LoadFile(path)
if err == nil || !strings.Contains(err.Error(), "invalid environment variable name") {
t.Fatalf("LoadFile() error = %v, want invalid secret filename", err)
}
if got := os.Getenv("A_SECRET"); got != "original-value" {
t.Fatalf("environment value = %q, want original-value after rejected load", got)
}
}
func TestLoadFileValidationFailureLeavesSecretEnvironmentUnset(t *testing.T) {
dir := t.TempDir()
secretsDir := filepath.Join(dir, "secrets")
if err := os.Mkdir(secretsDir, 0o700); err != nil {
t.Fatalf("create secrets directory: %v", err)
}
if err := os.WriteFile(filepath.Join(secretsDir, "WEATHERREPORTER_SECRET"), []byte("new-value"), 0o600); err != nil {
t.Fatalf("write secret: %v", err)
}
path := writeConfig(t, "secrets:\n directory: "+secretsDir+"\nmissing_source:\n default: invalid\n")
unsetEnvironment(t, "WEATHERREPORTER_SECRET")
_, err := LoadFile(path)
if err == nil || !strings.Contains(err.Error(), "missing_source.default") {
t.Fatalf("LoadFile() error = %v, want configuration validation error", err)
}
if _, set := os.LookupEnv("WEATHERREPORTER_SECRET"); set {
t.Fatal("WEATHERREPORTER_SECRET was set by a rejected configuration")
}
}
func TestApplySecretsRollsBackOnEnvironmentFailure(t *testing.T) {
t.Setenv("A_SECRET", "original-value")
unsetEnvironment(t, "Z_SECRET")
err := applySecrets([]secretValue{
{name: "A_SECRET", value: "new-value"},
{name: "Z_SECRET", value: "invalid\x00value"},
})
if err == nil || !strings.Contains(err.Error(), `secret file "Z_SECRET"`) {
t.Fatalf("applySecrets() error = %v, want Z_SECRET context", err)
}
if got := os.Getenv("A_SECRET"); got != "original-value" {
t.Fatalf("A_SECRET = %q, want original-value after rollback", got)
}
if _, set := os.LookupEnv("Z_SECRET"); set {
t.Fatal("Z_SECRET was set after failed environment application")
}
}
func unsetEnvironment(t *testing.T, name string) {
t.Helper()
value, set := os.LookupEnv(name)
if err := os.Unsetenv(name); err != nil {
t.Fatalf("unset environment variable %q: %v", name, err)
}
t.Cleanup(func() {
if set {
_ = os.Setenv(name, value)
return
}
_ = os.Unsetenv(name)
})
}