349 lines
12 KiB
Go
349 lines
12 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
)
|
|
|
|
func TestFetchAndSaveBundle(t *testing.T) {
|
|
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":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00"}]}}`))
|
|
case "/forecast/narrative":
|
|
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[]}}`))
|
|
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":[]}}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
path := filepath.Join(t.TempDir(), "bundle.json")
|
|
|
|
bundle, err := FetchAndSaveBundle(context.Background(), FetchBundleRequest{Config: cfg, OutputPath: path})
|
|
if err != nil {
|
|
t.Fatalf("FetchAndSaveBundle() error = %v", err)
|
|
}
|
|
if bundle.Hourly == nil {
|
|
t.Fatal("Hourly = nil, want fetched bundle")
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read saved bundle: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), `"product": "hourly"`) {
|
|
t.Fatalf("saved bundle missing hourly product:\n%s", string(data))
|
|
}
|
|
}
|
|
|
|
func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
|
|
_, err := FetchAndSaveBundle(context.Background(), FetchBundleRequest{Config: config.Defaults()})
|
|
if err == nil {
|
|
t.Fatal("FetchAndSaveBundle() error = nil, want output path error")
|
|
}
|
|
if !strings.Contains(err.Error(), "output path") {
|
|
t.Fatalf("error = %q, want output path context", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestGenerateDailyBriefingWritesArtifact(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
resolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportDaily,
|
|
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
}
|
|
path := filepath.Join(t.TempDir(), "daily.briefing.json")
|
|
|
|
result, err := GenerateDailyBriefing(context.Background(), DailyBriefingRequest{
|
|
Config: cfg,
|
|
Resolved: resolved,
|
|
OutputPath: path,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GenerateDailyBriefing() error = %v", err)
|
|
}
|
|
if result.OutputPath != path {
|
|
t.Fatalf("OutputPath = %q, want %q", result.OutputPath, path)
|
|
}
|
|
if result.Package.Daily == nil {
|
|
t.Fatal("Daily = nil")
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read briefing artifact: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), `"schemaVersion"`) || !strings.Contains(string(data), `"daily"`) {
|
|
t.Fatalf("briefing artifact missing expected fields:\n%s", string(data))
|
|
}
|
|
}
|
|
|
|
func TestGenerateDailyBriefingDefaultPath(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
cfg.Workspace.Root = t.TempDir()
|
|
resolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportDaily,
|
|
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
}
|
|
|
|
result, err := GenerateDailyBriefing(context.Background(), DailyBriefingRequest{
|
|
Config: cfg,
|
|
Resolved: resolved,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GenerateDailyBriefing() error = %v", err)
|
|
}
|
|
if !strings.HasSuffix(result.OutputPath, filepath.Join("snapshots", "daily", "2026-05-29", "2026-05-29.daily_today.briefing.json")) {
|
|
t.Fatalf("OutputPath = %q, want deterministic daily briefing path", result.OutputPath)
|
|
}
|
|
}
|
|
|
|
func TestPrepareDailyReportWritesDataPackageAndPreflight(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
cfg.Workspace.Root = t.TempDir()
|
|
resolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportDaily,
|
|
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
}
|
|
renderer := &recordingRenderer{
|
|
result: &scriptorium.RenderResult{
|
|
Command: []string{"scriptorium", "render"},
|
|
Stdout: `{"prepared":true}`,
|
|
ExitCode: 0,
|
|
},
|
|
}
|
|
dataPackagePath := filepath.Join(t.TempDir(), "daily.data_package.json")
|
|
|
|
result, err := PrepareDailyReport(context.Background(), DailyPreparationRequest{
|
|
Config: cfg,
|
|
Resolved: resolved,
|
|
DataPackagePath: dataPackagePath,
|
|
Renderer: renderer,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("PrepareDailyReport() error = %v", err)
|
|
}
|
|
|
|
if renderer.calls != 1 {
|
|
t.Fatalf("renderer calls = %d, want 1", renderer.calls)
|
|
}
|
|
if renderer.request.PromptID != "weather.daily_report" {
|
|
t.Fatalf("PromptID = %q, want weather.daily_report", renderer.request.PromptID)
|
|
}
|
|
if renderer.request.DataPackagePath != dataPackagePath {
|
|
t.Fatalf("DataPackagePath = %q, want %q", renderer.request.DataPackagePath, dataPackagePath)
|
|
}
|
|
for _, path := range []string{result.BriefingPath, result.DataPackagePath, result.PreflightPath} {
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Fatalf("expected artifact %q: %v", path, err)
|
|
}
|
|
}
|
|
data, err := os.ReadFile(dataPackagePath)
|
|
if err != nil {
|
|
t.Fatalf("read data package: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), `"recentChanges"`) || !strings.Contains(string(data), `data_package.v1`) {
|
|
t.Fatalf("data package missing expected content:\n%s", string(data))
|
|
}
|
|
preflight, err := os.ReadFile(result.PreflightPath)
|
|
if err != nil {
|
|
t.Fatalf("read preflight: %v", err)
|
|
}
|
|
if !strings.Contains(string(preflight), `prepared`) {
|
|
t.Fatalf("preflight output missing render stdout:\n%s", string(preflight))
|
|
}
|
|
}
|
|
|
|
func TestPrepareDailyReportPersistsFailedPreflight(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
cfg.Workspace.Root = t.TempDir()
|
|
resolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportDaily,
|
|
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
}
|
|
renderer := &recordingRenderer{
|
|
result: &scriptorium.RenderResult{
|
|
Command: []string{"scriptorium", "render"},
|
|
Stderr: "render failed",
|
|
ExitCode: 1,
|
|
},
|
|
err: errors.New("scriptorium render exited with code 1: render failed"),
|
|
}
|
|
|
|
_, err = PrepareDailyReport(context.Background(), DailyPreparationRequest{
|
|
Config: cfg,
|
|
Resolved: resolved,
|
|
Renderer: renderer,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("PrepareDailyReport() error = nil, want render error")
|
|
}
|
|
preflightPath := defaultPreflightPath(cfg, resolved)
|
|
preflight, readErr := os.ReadFile(preflightPath)
|
|
if readErr != nil {
|
|
t.Fatalf("read failed preflight: %v", readErr)
|
|
}
|
|
if !strings.Contains(string(preflight), `"exitCode": 1`) {
|
|
t.Fatalf("failed preflight was not persisted:\n%s", string(preflight))
|
|
}
|
|
}
|
|
|
|
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
now := mustParse("2026-05-29T18:00:00-05:00")
|
|
|
|
resolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportTomorrow,
|
|
}, now)
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
}
|
|
if resolved.Definition.ID != report.DailyTomorrow {
|
|
t.Fatalf("ID = %q, want daily_tomorrow", resolved.Definition.ID)
|
|
}
|
|
if resolved.Definition.PromptID != "weather.daily_report" {
|
|
t.Fatalf("PromptID = %q, want weather.daily_report", resolved.Definition.PromptID)
|
|
}
|
|
if got := resolved.ValidPeriod.Start.Format("2006-01-02"); got != "2026-05-30" {
|
|
t.Fatalf("valid start date = %s, want 2026-05-30", got)
|
|
}
|
|
}
|
|
|
|
func dailyBundleServer(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":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`))
|
|
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
|
|
}
|
|
|
|
func TestResolveGenerateStorm(t *testing.T) {
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
now := mustParse("2026-05-29T12:00:00-05:00")
|
|
start := mustParse("2026-05-29T18:00:00-05:00")
|
|
end := mustParse("2026-05-30T06:00:00-05:00")
|
|
|
|
resolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportStorm,
|
|
StormStart: start,
|
|
StormEnd: end,
|
|
}, now)
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
}
|
|
if resolved.Definition.ID != report.Storm {
|
|
t.Fatalf("ID = %q, want storm", resolved.Definition.ID)
|
|
}
|
|
if !resolved.ValidPeriod.Start.Equal(start) || !resolved.ValidPeriod.End.Equal(end) {
|
|
t.Fatalf("period = %#v, want storm window", resolved.ValidPeriod)
|
|
}
|
|
}
|
|
|
|
func TestResolveBatchMorningSkipsWeekendOnSunday(t *testing.T) {
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
now := mustParse("2026-05-31T06:00:00-05:00")
|
|
|
|
resolved, err := ResolveBatch(BatchRequest{Config: cfg, Batch: BatchMorning}, now)
|
|
if err != nil {
|
|
t.Fatalf("ResolveBatch() error = %v", err)
|
|
}
|
|
if len(resolved) != 2 {
|
|
t.Fatalf("resolved length = %d, want 2", len(resolved))
|
|
}
|
|
for _, item := range resolved {
|
|
if item.Definition.ID == report.Weekend {
|
|
t.Fatal("morning batch included weekend on Sunday")
|
|
}
|
|
}
|
|
}
|
|
|
|
func mustParse(value string) time.Time {
|
|
parsed, err := time.Parse(time.RFC3339, value)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
type recordingRenderer struct {
|
|
calls int
|
|
request scriptorium.RenderRequest
|
|
result *scriptorium.RenderResult
|
|
err error
|
|
}
|
|
|
|
func (r *recordingRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) {
|
|
r.calls++
|
|
r.request = req
|
|
return r.result, r.err
|
|
}
|