package cli import ( "bytes" "context" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/app" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) func TestRunHelpLongFlag(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer err := Run(context.Background(), []string{"--help"}, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } if !strings.Contains(stdout.String(), "generate daily") { t.Fatalf("help output missing generate command:\n%s", stdout.String()) } } func TestRunHelpShortFlag(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer err := Run(context.Background(), []string{"-h"}, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } if !strings.Contains(stdout.String(), "weatherreporter run evening") { t.Fatalf("help output missing run command:\n%s", stdout.String()) } } func TestRunUnknownCommand(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer err := Run(context.Background(), []string{"inspect"}, &stdout, &stderr) if err == nil { t.Fatal("Run() error = nil, want unknown command error") } if !strings.Contains(err.Error(), `unknown command "inspect"`) { t.Fatalf("Run() error = %q, want unknown command message", err.Error()) } } func TestRunGenerateReturnsNotImplementedAfterResolution(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer runner := Runner{Clock: fixedClock()} err := runner.Run(context.Background(), []string{"generate", "tomorrow", "--units", "metric"}, &stdout, &stderr) if err == nil { t.Fatal("Run() error = nil, want app not implemented error") } if !strings.Contains(err.Error(), "generate is not implemented") { t.Fatalf("Run() error = %q, want app not implemented error", err.Error()) } } func TestRunGenerateDailyWritesBriefing(t *testing.T) { server := dailyServer(t) configPath := filepath.Join(t.TempDir(), "config.yml") if err := os.WriteFile(configPath, []byte("weather_api:\n base_url: "+server.URL+"/\n timezone: America/Chicago\n"), 0o600); err != nil { t.Fatalf("write config: %v", err) } outPath := filepath.Join(t.TempDir(), "daily.briefing.json") var stdout bytes.Buffer var stderr bytes.Buffer runner := Runner{Clock: fixedClock()} err := runner.Run(context.Background(), []string{ "generate", "daily", "--config", configPath, "--date", "2026-05-29", "--out", outPath, }, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } data, err := os.ReadFile(outPath) if err != nil { t.Fatalf("read briefing: %v", err) } if !strings.Contains(string(data), `"schemaVersion"`) || !strings.Contains(string(data), `"daily_today"`) { t.Fatalf("briefing output missing expected content:\n%s", string(data)) } } func TestResolveGenerateCommands(t *testing.T) { runner := Runner{Clock: fixedClock()} tests := []struct { name string args []string want app.ReportKind }{ {name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily}, {name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow}, {name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay}, {name: "weekend", args: []string{"weekend"}, want: app.ReportWeekend}, {name: "storm", args: []string{"storm", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, want: app.ReportStorm}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req, err := runner.resolveGenerate(tt.args) if err != nil { t.Fatalf("resolveGenerate() error = %v", err) } if req.Report != tt.want { t.Fatalf("Report = %q, want %q", req.Report, tt.want) } }) } } func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) { runner := Runner{Clock: fixedClock()} req, err := runner.resolveGenerate([]string{"daily"}) if err != nil { t.Fatalf("resolveGenerate() error = %v", err) } if got := req.Date.Format(timeutil.DateLayout); got != "2026-05-29" { t.Fatalf("Date = %s, want 2026-05-29", got) } } func TestResolveGenerateAppliesSharedFlags(t *testing.T) { runner := Runner{Clock: fixedClock()} req, err := runner.resolveGenerate([]string{"daily", "--units", "metric", "--tz", "UTC", "--out", "./daily.md"}) if err != nil { t.Fatalf("resolveGenerate() error = %v", err) } if req.Config.WeatherAPI.Units != "metric" { t.Fatalf("Units = %q, want metric", req.Config.WeatherAPI.Units) } if req.Config.WeatherAPI.Timezone != "UTC" { t.Fatalf("Timezone = %q, want UTC", req.Config.WeatherAPI.Timezone) } if req.OutputPath != "./daily.md" { t.Fatalf("OutputPath = %q, want ./daily.md", req.OutputPath) } } func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) { runner := Runner{Clock: fixedClock()} _, err := runner.resolveGenerate([]string{"storm", "--start", "2026-05-29T18:00"}) if err == nil { t.Fatal("resolveGenerate() error = nil, want missing end error") } if !strings.Contains(err.Error(), "requires --end") { t.Fatalf("error = %q, want missing end", err.Error()) } } func TestResolveGenerateStormParsesRFC3339(t *testing.T) { runner := Runner{Clock: fixedClock()} req, err := runner.resolveGenerate([]string{ "storm", "--start", "2026-05-29T18:00:00-05:00", "--end", "2026-05-30T06:00:00-05:00", }) if err != nil { t.Fatalf("resolveGenerate() error = %v", err) } if !req.StormEnd.After(req.StormStart) { t.Fatalf("StormEnd = %s, want after %s", req.StormEnd, req.StormStart) } } func TestResolveRunCommands(t *testing.T) { tests := []struct { name string args []string want app.BatchKind }{ {name: "morning", args: []string{"morning"}, want: app.BatchMorning}, {name: "evening", args: []string{"evening", "--tz", "UTC"}, want: app.BatchEvening}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req, err := resolveRun(tt.args) if err != nil { t.Fatalf("resolveRun() error = %v", err) } if req.Batch != tt.want { t.Fatalf("Batch = %q, want %q", req.Batch, tt.want) } }) } } func TestResolveRunRejectsOutputFlag(t *testing.T) { _, err := resolveRun([]string{"morning", "--out", "./report.md"}) if err == nil { t.Fatal("resolveRun() error = nil, want flag error") } if !strings.Contains(err.Error(), "flag provided but not defined") { t.Fatalf("error = %q, want undefined flag error", err.Error()) } } func fixedClock() timeutil.Clock { return timeutil.FixedClock{Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)} } func dailyServer(t *testing.T) *httptest.Server { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/observations": _, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`)) case "/conditions/current": _, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`)) case "/forecast/hourly": _, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`)) case "/forecast/narrative": _, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Morning storms, then partly sunny."}]}}`)) case "/alerts/active": _, _ = w.Write([]byte(`{"data":{"alerts":[]}}`)) case "/discussion": _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`)) default: http.NotFound(w, r) } })) t.Cleanup(server.Close) return server }