From a2069793076862448a5c888c949221b1a73915f0 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 31 Jul 2026 17:22:19 +0000 Subject: [PATCH] Restore CLI and inspection coverage --- internal/cli/root_test.go | 594 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 594 insertions(+) create mode 100644 internal/cli/root_test.go diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 0000000..32ad672 --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,594 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/app" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" + "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/state" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +const ( + testRenderedPrompt = "PRIVATE RENDERED PROMPT" + testSchemaBody = `{"private":"schema"}` + testDataBody = "PRIVATE DATA PACKAGE" + testGeneratedBody = "PRIVATE GENERATED BODY" + testEndpoint = "https://user:credential@example.invalid/v1?token=credential" + testParameters = `{"temperature":0.2,"private":"parameter"}` + testCredential = "cli-secret-credential" +) + +type commandOutput struct { + stdout string + stderr string +} + +type cliExecutor struct { + fail bool + failPrompt string +} + +func (e cliExecutor) InspectPrompt(_ context.Context, id, version string) (promptexec.PromptInspection, error) { + name := strings.TrimSuffix(strings.TrimPrefix(id, "weather."), "_generated_text") + return promptexec.PromptInspection{ + PromptID: id, PromptVersion: version, PromptHash: "prompt-hash", DefaultProfileID: "offline-profile", + Inputs: []promptexec.InputDefinition{{Name: "data_package", Required: true, ContentType: "application/yaml"}}, + Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: name + ".generated_text.schema.json"}, + }, nil +} + +func (e cliExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) { + return promptexec.ProfileInspection{ProfileID: id, BackendID: "offline", ModelName: "offline-model"}, nil +} + +func (e cliExecutor) Execute(_ context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) { + now := time.Date(2026, 5, 29, 12, 1, 0, 0, time.UTC) + preparation := promptexec.Preparation{ + PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", + ProfileID: req.ProfileID, BackendID: "offline", ModelName: "offline-model", DataPackagePath: req.DataPackagePath, + StartedAt: now, EndedAt: now, + } + if err := callback(preparation, nil); err != nil { + return nil, err + } + if e.fail || req.PromptID == e.failPrompt { + return nil, errors.New(strings.Join([]string{ + "provider failed", testEndpoint, testCredential, testRenderedPrompt, testSchemaBody, testDataBody, testGeneratedBody, testParameters, + }, " ")) + } + raw := []byte(`{"summary":"Showers are possible.","forecast_discussion":["Rain chances continue."],"precipitation_timing":"Rain is most likely this afternoon."}`) + if req.PromptID == "weather.hourly_generated_text" { + raw = []byte(`{"summary":"Storm chances increase.","forecast_discussion":"A front keeps the area unsettled.","precipitation_timing":"Rain is most likely late this morning."}`) + } + return &promptexec.Execution{ + RunID: "offline-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", + ProfileID: req.ProfileID, BackendID: "offline", ModelName: "offline-model", GeneratedHash: "generated-hash", + StartedAt: now, EndedAt: now, DataPackagePath: req.DataPackagePath, RawOutput: raw, + Validation: promptexec.NewValidation(promptexec.ValidationPassed, "json_schema", "generated_text.schema.json", nil), + }, nil +} + +func TestRunnerHelpListsOnlySupportedCommands(t *testing.T) { + output, err := runCLICommand(Runner{}, "--help") + if err != nil { + t.Fatalf("Run(--help) error = %v", err) + } + for _, command := range []string{ + "generate daily", "generate today", "generate tomorrow", "generate hourly", "run morning", "run evening", + "inspect reports", "inspect metadata", "inspect modules", "inspect data-package", "inspect prior", "inspect sources", + } { + if !strings.Contains(output.stdout, command) { + t.Fatalf("help missing %q:\n%s", command, output.stdout) + } + } + for _, retired := range []string{"near-term", "three-day", "weekend", "storm"} { + if strings.Contains(output.stdout, retired) { + t.Fatalf("help contains retired command %q:\n%s", retired, output.stdout) + } + } +} + +func TestResolveSupportedCommandsAndFlags(t *testing.T) { + configPath := writeCLIConfig(t, t.TempDir(), "") + runner, constructions := countingRunner(cliExecutor{}) + + for _, tt := range []struct { + name string + args []string + want app.ReportKind + }{ + {name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily}, + {name: "today", args: []string{"today"}, want: app.ReportToday}, + {name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow}, + {name: "hourly", args: []string{"hourly"}, want: app.ReportHourly}, + } { + t.Run(tt.name, func(t *testing.T) { + args := append(tt.args, "--config", configPath) + req, err := runner.resolveGenerate(args) + if err != nil { + t.Fatalf("resolveGenerate() error = %v", err) + } + if req.Report != tt.want || req.Executor == nil { + t.Fatalf("request = %#v, want report %q with executor", req, tt.want) + } + if tt.want == app.ReportDaily || tt.want == app.ReportToday { + if got := req.Date.Format(timeutil.DateLayout); got != "2026-05-29" { + t.Fatalf("resolved date = %q, want 2026-05-29", got) + } + } else if !req.Date.IsZero() { + t.Fatalf("resolved date = %s, want unset", req.Date) + } + }) + } + + for _, tt := range []struct { + name string + want app.BatchKind + }{ + {name: "morning", want: app.BatchMorning}, + {name: "evening", want: app.BatchEvening}, + } { + t.Run(tt.name, func(t *testing.T) { + req, err := runner.resolveRun([]string{tt.name, "--config", configPath}) + if err != nil { + t.Fatalf("resolveRun() error = %v", err) + } + if req.Batch != tt.want || req.Executor == nil { + t.Fatalf("request = %#v, want batch %q with executor", req, tt.want) + } + }) + } + if *constructions != 6 { + t.Fatalf("executor constructions = %d, want one per resolved action", *constructions) + } +} + +func TestResolveGenerateAndRunApplySharedActionFlags(t *testing.T) { + configPath := writeCLIConfig(t, t.TempDir(), "") + runner, _ := countingRunner(cliExecutor{}) + + generate, generateOpts, err := runner.resolveGenerateAction([]string{ + "daily", "--config", configPath, "--date", "2026-05-30", "--units", "metric", "--tz", "UTC", + "--out", "daily.md", "--llm-debug-dir", "/safe/debug", "--quiet", + }) + if err != nil { + t.Fatalf("resolveGenerateAction() error = %v", err) + } + if generate.Config.WeatherAPI.Units != "metric" || generate.Config.WeatherAPI.Timezone != "UTC" || generate.OutputPath != "daily.md" || generate.LLMDebugDir != "/safe/debug" || !generateOpts.Quiet { + t.Fatalf("generate request/options = %#v/%#v", generate, generateOpts) + } + if got := generate.Date.Format(timeutil.DateLayout); got != "2026-05-30" { + t.Fatalf("generate date = %q, want 2026-05-30", got) + } + + batch, batchOpts, err := runner.resolveRunAction([]string{ + "evening", "--config", configPath, "--units", "metric", "--tz", "UTC", "--out-dir", "reports", + "--llm-debug-dir", "/safe/debug", "--quiet", + }) + if err != nil { + t.Fatalf("resolveRunAction() error = %v", err) + } + if batch.Config.WeatherAPI.Units != "metric" || batch.Config.WeatherAPI.Timezone != "UTC" || batch.OutputDir != "reports" || batch.LLMDebugDir != "/safe/debug" || !batchOpts.Quiet { + t.Fatalf("batch request/options = %#v/%#v", batch, batchOpts) + } +} + +func TestGenerateFlagContracts(t *testing.T) { + for _, kind := range []app.ReportKind{app.ReportDaily, app.ReportToday, app.ReportTomorrow, app.ReportHourly} { + t.Run(string(kind), func(t *testing.T) { + opts, err := parseGenerateFlags(kind, []string{"--llm-debug-dir", "/safe/debug", "--quiet", "--out", "report.md"}) + if err != nil || opts.LLMDebugDir != "/safe/debug" || !opts.Quiet || opts.Output != "report.md" { + t.Fatalf("parseGenerateFlags() = %#v, %v", opts, err) + } + }) + } + + runner, _ := countingRunner(cliExecutor{}) + for _, tt := range []struct { + name string + args []string + want string + }{ + {name: "daily requires date", args: []string{"daily"}, want: "requires --date"}, + {name: "malformed daily date", args: []string{"daily", "--date", "bad-date"}, want: "YYYY-MM-DD"}, + {name: "malformed today date", args: []string{"today", "--date", "bad-date"}, want: "YYYY-MM-DD"}, + {name: "tomorrow rejects date", args: []string{"tomorrow", "--date", "2026-05-29"}}, + {name: "hourly rejects date", args: []string{"hourly", "--date", "2026-05-29"}}, + {name: "hourly rejects hours", args: []string{"hourly", "--hours", "6"}}, + {name: "hourly rejects duration", args: []string{"hourly", "--duration", "6h"}}, + {name: "batch rejects output", args: []string{"run", "--out", "report.md"}}, + } { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.args[0] == "run" { + _, err = runner.resolveRun(append([]string{"morning"}, tt.args[1:]...)) + } else { + _, err = runner.resolveGenerate(tt.args) + } + if err == nil || (tt.want != "" && !strings.Contains(err.Error(), tt.want)) { + t.Fatalf("error = %v, want rejection containing %q", err, tt.want) + } + }) + } +} + +func TestResolversRejectRetiredAndUnknownNames(t *testing.T) { + runner, _ := countingRunner(cliExecutor{}) + for _, name := range []string{"near-term", "three-day", "weekend", "storm"} { + if _, err := runner.resolveGenerate([]string{name}); err == nil || !strings.Contains(err.Error(), "unknown generate report") { + t.Fatalf("resolveGenerate(%q) error = %v", name, err) + } + } + for _, name := range []string{"daily", "weekend", "storm"} { + if _, err := runner.resolveRun([]string{name}); err == nil || !strings.Contains(err.Error(), "unknown run batch") { + t.Fatalf("resolveRun(%q) error = %v", name, err) + } + } +} + +func TestRunnerSuccessfulSingleAndBatchActions(t *testing.T) { + for _, tt := range []struct { + name string + args func(string, string) []string + }{ + {name: "single", args: func(configPath, outputPath string) []string { + return []string{"generate", "today", "--config", configPath, "--out", outputPath} + }}, + {name: "batch", args: func(configPath, outputPath string) []string { + return []string{"run", "evening", "--config", configPath, "--out-dir", outputPath} + }}, + } { + t.Run(tt.name, func(t *testing.T) { + fixture := newCLIFixture(t) + runner, constructions := countingRunner(cliExecutor{}) + output, err := runCLICommand(runner, tt.args(fixture.configPath, fixture.path("copies"))...) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if *constructions != 1 { + t.Fatalf("executor constructions = %d, want 1", *constructions) + } + assertRoutineOutputSafe(t, output) + if tt.name == "single" { + summary := decodeGenerateSummary(t, output.stdout) + if summary.Status != summaryStatusSucceeded || summary.ReportID != report.Today || summary.ReportPath == "" || summary.OutputPath == "" || summary.MetadataPath == "" || summary.DataPackagePath == "" || summary.PreparationPath == "" || summary.ExecutionPath == "" { + t.Fatalf("single summary = %#v", summary) + } + if strings.Contains(output.stdout, `"notification"`) || strings.Contains(output.stdout, `"llmDebugPath"`) { + t.Fatalf("single summary contains absent optional fields:\n%s", output.stdout) + } + } else { + summary := decodeBatchSummary(t, output.stdout) + if summary.Status != summaryStatusSucceeded || summary.Batch != app.BatchEvening || summary.Total != 1 || len(summary.Reports) != 1 || summary.Reports[0].OutputPath == "" { + t.Fatalf("batch summary = %#v", summary) + } + if !strings.Contains(output.stderr, "report=tomorrow status=succeeded") || !strings.Contains(output.stderr, "batch=evening total=1 succeeded=1 failed=0") { + t.Fatalf("batch status = %q", output.stderr) + } + if strings.Contains(output.stdout, `"notification"`) || strings.Contains(output.stdout, `"llmDebugPath"`) { + t.Fatalf("batch summary contains absent optional fields:\n%s", output.stdout) + } + } + }) + } +} + +func TestRunnerPreRunFailureAndQuietMode(t *testing.T) { + runner, constructions := countingRunner(cliExecutor{}) + output, err := runCLICommand(runner, "generate", "daily") + if err == nil || output.stdout != "" || output.stderr != "" { + t.Fatalf("pre-run output/error = %#v/%v, want error without summary", output, err) + } + if *constructions != 1 { + t.Fatalf("executor constructions = %d, want one action-scoped construction", *constructions) + } + + fixture := newCLIFixture(t) + runner, _ = countingRunner(cliExecutor{}) + output, err = runCLICommand(runner, "generate", "today", "--config", fixture.configPath, "--quiet") + if err != nil || output.stdout != "" || output.stderr != "" { + t.Fatalf("quiet output/error = %#v/%v", output, err) + } +} + +func TestRunnerFailedActionReportsSafePartialSummary(t *testing.T) { + fixture := newCLIFixture(t) + runner, constructions := countingRunner(cliExecutor{fail: true}) + output, err := runCLICommand(runner, "generate", "today", "--config", fixture.configPath) + if err == nil { + t.Fatal("Run() error = nil, want execution failure") + } + if *constructions != 1 { + t.Fatalf("executor constructions = %d, want 1", *constructions) + } + summary := decodeGenerateSummary(t, output.stdout) + if summary.Status != summaryStatusFailed || summary.RunID == "" || summary.MetadataPath == "" || summary.DataPackagePath == "" || summary.PreparationPath == "" || summary.ExecutionPath == "" || summary.ReportPath != "" { + t.Fatalf("failed summary paths = %#v", summary) + } + if !strings.Contains(summary.Error, "prompt execution failed") { + t.Fatalf("failed summary error = %q", summary.Error) + } + assertRoutineOutputSafe(t, output) + + for _, command := range []string{"metadata", "modules", "data-package", "sources"} { + inspected, inspectErr := runCLICommand(runner, "inspect", command, "--config", fixture.configPath, summary.RunID) + if inspectErr != nil { + t.Fatalf("inspect %s error = %v", command, inspectErr) + } + if !strings.Contains(inspected.stdout, summary.RunID) { + t.Fatalf("inspect %s missing failed run id:\n%s", command, inspected.stdout) + } + assertRoutineOutputSafe(t, inspected) + } +} + +func TestRunnerMixedBatchReportsSafePartialFailure(t *testing.T) { + fixture := newCLIFixture(t) + runner, constructions := countingRunner(cliExecutor{failPrompt: "weather.tomorrow_generated_text"}) + output, err := runCLICommand(runner, "run", "morning", "--config", fixture.configPath) + if err == nil { + t.Fatal("Run() error = nil, want aggregate batch failure") + } + if *constructions != 1 { + t.Fatalf("executor constructions = %d, want 1", *constructions) + } + summary := decodeBatchSummary(t, output.stdout) + if summary.Status != summaryStatusFailed || summary.Total != 2 || summary.Succeeded != 1 || summary.Failed != 1 || len(summary.Reports) != 2 { + t.Fatalf("failed batch summary = %#v", summary) + } + var succeeded, failed *app.BatchReportResult + for index := range summary.Reports { + item := &summary.Reports[index] + if item.Status == summaryStatusSucceeded { + succeeded = item + } else if item.Status == summaryStatusFailed { + failed = item + } + } + if succeeded == nil || succeeded.ReportPath == "" || succeeded.MetadataPath == "" || succeeded.ExecutionPath == "" { + t.Fatalf("successful batch item paths = %#v", succeeded) + } + if failed == nil || failed.ReportPath != "" || failed.MetadataPath == "" || failed.DataPackagePath == "" || failed.PreparationPath == "" || failed.ExecutionPath == "" { + t.Fatalf("failed batch item paths = %#v", failed) + } + if !strings.Contains(output.stderr, "status=succeeded") || !strings.Contains(output.stderr, "status=failed") || !strings.Contains(output.stderr, "batch=morning total=2 succeeded=1 failed=1") { + t.Fatalf("partial batch status = %q", output.stderr) + } + assertRoutineOutputSafe(t, output) +} + +func TestRunnerInspectsReportsAndCurrentArtifacts(t *testing.T) { + fixture := newCLIFixture(t) + runner, _ := countingRunner(cliExecutor{}) + first := runSuccessfulGenerate(t, runner, fixture.configPath, time.Date(2026, 5, 29, 11, 0, 0, 0, time.UTC)) + second := runSuccessfulGenerate(t, runner, fixture.configPath, time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)) + + listed, err := runCLICommand(runner, "inspect", "reports", "--config", fixture.configPath, "--limit", "2") + if err != nil || !strings.Contains(listed.stdout, first.RunID) || !strings.Contains(listed.stdout, second.RunID) { + t.Fatalf("inspect reports output/error = %s/%v", listed.stdout, err) + } + for _, command := range []string{"metadata", "modules", "data-package", "sources"} { + output, inspectErr := runCLICommand(runner, "inspect", command, "--config", fixture.configPath, second.RunID) + if inspectErr != nil || !strings.Contains(output.stdout, second.RunID) { + t.Fatalf("inspect %s output/error = %s/%v", command, output.stdout, inspectErr) + } + assertRoutineOutputSafe(t, output) + } + prior, err := runCLICommand(runner, "inspect", "prior", "--config", fixture.configPath, second.RunID) + if err != nil || !strings.Contains(prior.stdout, first.RunID) { + t.Fatalf("inspect prior output/error = %s/%v", prior.stdout, err) + } +} + +func TestRunnerInspectsHistoricalMetadataAndArtifacts(t *testing.T) { + fixture := newCLIFixture(t) + runIDs := []string{ + writeHistoricalInspectionFixture(t, fixture.workspaceRoot, report.ID("three_day"), time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)), + writeHistoricalInspectionFixture(t, fixture.workspaceRoot, report.ID("weekend"), time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC)), + writeHistoricalInspectionFixture(t, fixture.workspaceRoot, report.ID("storm"), time.Date(2026, 5, 22, 12, 0, 0, 0, time.UTC)), + } + runID := runIDs[0] + runner, _ := countingRunner(cliExecutor{}) + + listed, err := runCLICommand(runner, "inspect", "reports", "--config", fixture.configPath) + if err != nil { + t.Fatalf("inspect historical reports output/error = %s/%v", listed.stdout, err) + } + for _, want := range []string{runIDs[0], runIDs[1], runIDs[2], `"reportId": "three_day"`, `"reportId": "weekend"`, `"reportId": "storm"`} { + if !strings.Contains(listed.stdout, want) { + t.Fatalf("inspect historical reports missing %q:\n%s", want, listed.stdout) + } + } + for _, command := range []string{"metadata", "modules", "data-package", "sources"} { + output, inspectErr := runCLICommand(runner, "inspect", command, "--config", fixture.configPath, runID) + if inspectErr != nil || !strings.Contains(output.stdout, runID) { + t.Fatalf("inspect historical %s output/error = %s/%v", command, output.stdout, inspectErr) + } + } + metadata, err := runCLICommand(runner, "inspect", "metadata", "--config", fixture.configPath, runID) + if err != nil || !strings.Contains(metadata.stdout, `"schemaVersion": "weatherreporter.metadata.v1"`) || !strings.Contains(metadata.stdout, `"preflightPath"`) || strings.Contains(metadata.stdout, `"preparationPath"`) { + t.Fatalf("historical metadata aliases/output = %s/%v", metadata.stdout, err) + } +} + +func countingRunner(executor promptexec.Executor) (Runner, *int) { + count := new(int) + return Runner{ + Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)}, + ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) { + *count++ + return executor, nil + }, + }, count +} + +func runCLICommand(runner Runner, args ...string) (commandOutput, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + err := runner.Run(context.Background(), args, &stdout, &stderr) + return commandOutput{stdout: stdout.String(), stderr: stderr.String()}, err +} + +func runSuccessfulGenerate(t *testing.T, base Runner, configPath string, now time.Time) generateSummary { + t.Helper() + base.Clock = timeutil.FixedClock{Time: now} + output, err := runCLICommand(base, "generate", "today", "--config", configPath) + if err != nil { + t.Fatalf("generate current report: %v", err) + } + return decodeGenerateSummary(t, output.stdout) +} + +func decodeGenerateSummary(t *testing.T, text string) generateSummary { + t.Helper() + var summary generateSummary + if err := json.Unmarshal([]byte(text), &summary); err != nil { + t.Fatalf("decode generate summary: %v\n%s", err, text) + } + return summary +} + +func decodeBatchSummary(t *testing.T, text string) batchSummary { + t.Helper() + var summary batchSummary + if err := json.Unmarshal([]byte(text), &summary); err != nil { + t.Fatalf("decode batch summary: %v\n%s", err, text) + } + return summary +} + +func assertRoutineOutputSafe(t *testing.T, output commandOutput) { + t.Helper() + combined := output.stdout + output.stderr + for _, forbidden := range []string{testRenderedPrompt, testSchemaBody, testDataBody, testGeneratedBody, testEndpoint, testParameters, testCredential, "credential@example.invalid"} { + if strings.Contains(combined, forbidden) { + t.Fatalf("routine output contains sensitive value %q:\n%s", forbidden, combined) + } + } +} + +type cliFixture struct { + tempDir string + workspaceRoot string + configPath string +} + +func newCLIFixture(t *testing.T) cliFixture { + t.Helper() + tempDir := t.TempDir() + workspaceRoot := filepath.Join(tempDir, "workspace") + server := weatherServer(t) + return cliFixture{tempDir: tempDir, workspaceRoot: workspaceRoot, configPath: writeCLIConfig(t, workspaceRoot, server.URL+"/")} +} + +func (f cliFixture) path(name string) string { return filepath.Join(f.tempDir, name) } + +func writeCLIConfig(t *testing.T, workspaceRoot, baseURL string) string { + t.Helper() + configPath := filepath.Join(t.TempDir(), "config.yml") + body := "weather_api:\n timezone: America/Chicago\n" + if baseURL != "" { + body += " base_url: " + baseURL + "\n" + } + body += "workspace:\n root: " + workspaceRoot + "\n" + if err := os.WriteFile(configPath, []byte(body), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + return configPath +} + +func weatherServer(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","temperatureF":66,"probabilityOfPrecipitationPercent":80},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers","temperatureF":67,"probabilityOfPrecipitationPercent":70}]}}`)) + 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 showers."},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T18:00:00-05:00","textDescription":"Tomorrow starts showery."}]}}`)) + 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":["Showers remain possible."]}}`)) + case "/weatherstories/latest": + _, _ = w.Write([]byte(`{"data":null}`)) + case "/outlooks/convective": + _, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + return server +} + +func writeHistoricalInspectionFixture(t *testing.T, workspaceRoot string, reportID report.ID, generatedAt time.Time) string { + t.Helper() + runID := "historical-" + string(reportID) + date := generatedAt.Format(timeutil.DateLayout) + dir := filepath.Join(workspaceRoot, "snapshots", string(reportID), date) + modulePath := filepath.Join(dir, "modules."+runID+".json") + dataPath := filepath.Join(workspaceRoot, "data-packages", string(reportID), date, "data_package."+runID+".yaml") + metadataPath := filepath.Join(dir, "metadata."+runID+".json") + if err := os.MkdirAll(filepath.Dir(dataPath), 0o755); err != nil { + t.Fatalf("create historical fixture directory: %v", err) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("create historical metadata directory: %v", err) + } + snapshot, err := module.NewSnapshot([]module.Output{{ID: module.Metadata, StanzaName: "metadata", Value: map[string]any{"run_id": runID}}}) + if err != nil { + t.Fatalf("build historical module snapshot: %v", err) + } + moduleData, err := json.Marshal(snapshot) + if err != nil { + t.Fatalf("marshal historical module snapshot: %v", err) + } + if err := os.WriteFile(modulePath, moduleData, 0o600); err != nil { + t.Fatalf("write historical module snapshot: %v", err) + } + period := timeutil.Period{Start: generatedAt, End: generatedAt.Add(24 * time.Hour)} + pkg, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptinput.Metadata{ + RunID: runID, ReportID: reportID, PromptID: "weather." + string(reportID), GeneratedAt: generatedAt, Timezone: "UTC", ValidPeriod: period, + }, Modules: snapshot}) + if err != nil { + t.Fatalf("build historical data package: %v", err) + } + data, err := promptinput.MarshalYAML(pkg) + if err != nil { + t.Fatalf("marshal historical data package: %v", err) + } + if err := os.WriteFile(dataPath, data, 0o600); err != nil { + t.Fatalf("write historical data package: %v", err) + } + metadata := state.Metadata{ + SchemaVersion: state.MetadataSchemaVersionV1, RunID: runID, ReportID: reportID, PromptID: "weather." + string(reportID), + GeneratedAt: generatedAt, Timezone: "UTC", ValidPeriod: period, SourceLocation: "historical archive", + ModuleSnapshotPath: modulePath, DataPackagePath: dataPath, PreflightPath: "/archive/preflight.json", GeneratedTextResultPath: "/archive/result.json", + } + metadataData, err := json.Marshal(metadata) + if err != nil { + t.Fatalf("marshal historical metadata: %v", err) + } + if err := os.WriteFile(metadataPath, metadataData, 0o600); err != nil { + t.Fatalf("write historical metadata: %v", err) + } + return runID +}