Files
weatherreporter/internal/cli/root_test.go

250 lines
7.8 KiB
Go

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
}