1058 lines
31 KiB
Go
1058 lines
31 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
)
|
|
|
|
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.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.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)
|
|
}
|
|
wantReportPaths := []string{
|
|
"{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md",
|
|
}
|
|
if strings.Join(cfg.Notify.Distributor.ReportPathTemplates, "\n") != strings.Join(wantReportPaths, "\n") {
|
|
t.Fatalf("Notify.Distributor.ReportPathTemplates = %#v, want %#v", cfg.Notify.Distributor.ReportPathTemplates, wantReportPaths)
|
|
}
|
|
if cfg.MissingSource.Default != MissingSourceWarn {
|
|
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
|
|
}
|
|
}
|
|
|
|
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.rakestrawhome.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.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{report_id}" {
|
|
t.Fatalf("PipelineIDTemplate = %q, want example pipeline template", cfg.Notify.Distributor.PipelineIDTemplate)
|
|
}
|
|
if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 {
|
|
t.Fatalf("ReportPathTemplates = %#v, want example archive path", cfg.Notify.Distributor.ReportPathTemplates)
|
|
}
|
|
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.Scriptorium.Binary != "scriptorium" {
|
|
t.Fatalf("Scriptorium.Binary = %q, want default scriptorium", cfg.Scriptorium.Binary)
|
|
}
|
|
if cfg.Workspace.Root != "workspace" {
|
|
t.Fatalf("Workspace.Root = %q, want default workspace", cfg.Workspace.Root)
|
|
}
|
|
if cfg.Workspace.NotificationsDir != "notifications" {
|
|
t.Fatalf("Workspace.NotificationsDir = %q, want notifications", cfg.Workspace.NotificationsDir)
|
|
}
|
|
if cfg.Location.Name != "Brentwood" {
|
|
t.Fatalf("Location.Name = %q, want default Brentwood", cfg.Location.Name)
|
|
}
|
|
}
|
|
|
|
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 TestLoadReportModuleOverrideAliases(t *testing.T) {
|
|
path := writeConfig(t, `
|
|
reports:
|
|
three-day-outlook:
|
|
deterministic_modules:
|
|
- metadata
|
|
weekend_outlook:
|
|
deterministic_modules:
|
|
- metadata
|
|
storm_report:
|
|
deterministic_modules:
|
|
- metadata
|
|
`)
|
|
|
|
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)
|
|
}
|
|
if len(overrides[report.ThreeDay]) != 1 || overrides[report.ThreeDay][0].ID != module.Metadata {
|
|
t.Fatalf("three-day alias override = %#v, want metadata override", overrides[report.ThreeDay])
|
|
}
|
|
if len(overrides[report.Weekend]) != 1 || overrides[report.Weekend][0].ID != module.Metadata {
|
|
t.Fatalf("weekend alias override = %#v, want metadata override", overrides[report.Weekend])
|
|
}
|
|
if len(overrides[report.Storm]) != 1 || overrides[report.Storm][0].ID != module.Metadata {
|
|
t.Fatalf("storm alias override = %#v, want metadata override", overrides[report.Storm])
|
|
}
|
|
}
|
|
|
|
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 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 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 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 TestEnabledDistributorNotifyValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*Config)
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "Endpoint",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Notify.Distributor.Endpoint = "distributor.example.com"
|
|
},
|
|
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: "IdempotencyTemplate",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Notify.Distributor.IdempotencyKeyTemplate = "{unknown}"
|
|
},
|
|
wantErr: "notify.distributor.idempotency_key_template",
|
|
},
|
|
{
|
|
name: "ReportPathTemplatesEmpty",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Notify.Distributor.ReportPathTemplates = nil
|
|
},
|
|
wantErr: "notify.distributor.report_path_templates",
|
|
},
|
|
{
|
|
name: "ReportPathTemplateUnknown",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Notify.Distributor.ReportPathTemplates = []string{"{unknown}"}
|
|
},
|
|
wantErr: "notify.distributor.report_path_templates",
|
|
},
|
|
{
|
|
name: "ReportPathTemplateInvalidPath",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Notify.Distributor.ReportPathTemplates = []string{"/{batch_output_name}"}
|
|
},
|
|
wantErr: "notify.distributor.report_path_templates",
|
|
},
|
|
{
|
|
name: "ReportPathTemplateDuplicatePath",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Notify.Distributor.ReportPathTemplates = []string{"latest.md", "latest.md"}
|
|
},
|
|
wantErr: "notify.distributor.report_path_templates",
|
|
},
|
|
}
|
|
|
|
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 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}", values)
|
|
if err != nil {
|
|
t.Fatalf("RenderDistributorBundleID() error = %v", err)
|
|
}
|
|
if bundleID != "weatherreporter.home.daily" {
|
|
t.Fatalf("bundleID = %q, want rendered value", bundleID)
|
|
}
|
|
|
|
pipelineID, err := RenderDistributorPipelineID("weatherreporter.{artifact_group}.{bundle_id}", values)
|
|
if err != nil {
|
|
t.Fatalf("RenderDistributorPipelineID() error = %v", err)
|
|
}
|
|
if pipelineID != "weatherreporter.daily.weatherreporter.home.daily" {
|
|
t.Fatalf("pipelineID = %q, want rendered pipeline ID", pipelineID)
|
|
}
|
|
|
|
idempotencyKey, err := RenderDistributorIdempotencyKey("{bundle_id}.{run_id}", values)
|
|
if err != nil {
|
|
t.Fatalf("RenderDistributorIdempotencyKey() error = %v", err)
|
|
}
|
|
if idempotencyKey != "weatherreporter.home.daily.20260607T120000Z" {
|
|
t.Fatalf("idempotencyKey = %q, want rendered run key", idempotencyKey)
|
|
}
|
|
|
|
reportPaths, err := RenderDistributorReportPaths([]string{
|
|
"{valid_start_date}/{artifact_group}/{valid_start_stamp}-{valid_end_stamp}-{run_id}.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",
|
|
"2026-06-07/daily/latest.md",
|
|
}
|
|
if strings.Join(reportPaths, "\n") != strings.Join(wantPaths, "\n") {
|
|
t.Fatalf("reportPaths = %#v, want %#v", reportPaths, wantPaths)
|
|
}
|
|
}
|
|
|
|
func TestDistributorTemplateRejectsUnknownAndMalformedVariables(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
template string
|
|
}{
|
|
{name: "Unknown", template: "{unknown}"},
|
|
{name: "Unclosed", template: "{location_id"},
|
|
{name: "Unopened", template: "location_id}"},
|
|
{name: "Empty", template: "{}"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := RenderDistributorBundleID(tt.template, DistributorTemplateValues{})
|
|
if err == nil {
|
|
t.Fatal("RenderDistributorBundleID() error = nil, want error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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/.distributor.json", 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: ".distributor.json"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := RenderDistributorReportPaths([]string{"{batch_output_name}"}, DistributorTemplateValues{
|
|
BatchOutputName: tt.batchOutputName,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("RenderDistributorReportPaths() error = nil, want 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")
|
|
|
|
if err := loadSecrets(SecretsConfig{}); err != nil {
|
|
t.Fatalf("loadSecrets() error = %v", err)
|
|
}
|
|
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")
|
|
|
|
if err := loadSecrets(SecretsConfig{Directory: dir}); err != nil {
|
|
t.Fatalf("loadSecrets() 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", "")
|
|
|
|
if err := loadSecrets(SecretsConfig{Directory: dir}); err != nil {
|
|
t.Fatalf("loadSecrets() 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)
|
|
}
|
|
if err := os.Symlink(target, filepath.Join(dir, "SYMLINK")); err != nil {
|
|
t.Fatalf("create symlink: %v", err)
|
|
}
|
|
},
|
|
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)
|
|
})
|
|
},
|
|
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())
|
|
}
|
|
}
|