2026-07-31 17:22:19 +00:00
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 {
2026-07-31 19:13:45 +00:00
"--version" ,
2026-07-31 17:22:19 +00:00
"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 )
}
}
2026-08-01 19:33:12 +00:00
for _ , description := range [ ] string { "Write the generated Markdown report to PATH." , "Write generated Markdown reports beneath PATH" } {
if ! strings . Contains ( output . stdout , description ) {
t . Fatalf ( "help missing output description %q:\n%s" , description , output . stdout )
}
}
2026-07-31 17:22:19 +00:00
}
2026-07-31 19:13:45 +00:00
func TestRunnerVersion ( t * testing . T ) {
for _ , test := range [ ] struct {
name string
runner Runner
version string
} {
{ name : "development default" , runner : Runner { } , version : "development" } ,
{ name : "injected release" , runner : Runner { Version : "v0.9.0-test" } , version : "v0.9.0-test" } ,
} {
t . Run ( test . name , func ( t * testing . T ) {
output , err := runCLICommand ( test . runner , "--version" )
if err != nil {
t . Fatalf ( "Run(--version) error = %v" , err )
}
if output . stdout != "weatherreporter " + test . version + "\n" || output . stderr != "" {
t . Fatalf ( "Run(--version) output = stdout %q stderr %q" , output . stdout , output . stderr )
}
} )
}
if _ , err := runCLICommand ( Runner { Version : "v0.9.0-test" } , "--version" , "extra" ) ; err == nil {
t . Fatal ( "Run(--version extra) error = nil" )
}
}
2026-07-31 17:22:19 +00:00
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 { } )
2026-08-01 19:33:12 +00:00
runner . WorkingDir = t . TempDir ( )
2026-07-31 17:22:19 +00:00
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 )
}
2026-08-01 19:33:12 +00:00
if generate . Config . WeatherAPI . Units != "metric" || generate . Config . WeatherAPI . Timezone != "UTC" || generate . OutputPath != filepath . Join ( runner . WorkingDir , "daily.md" ) || generate . WorkingDir != runner . WorkingDir || generate . LLMDebugDir != "/safe/debug" || ! generateOpts . Quiet {
2026-07-31 17:22:19 +00:00
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 )
}
2026-08-01 19:33:12 +00:00
if batch . Config . WeatherAPI . Units != "metric" || batch . Config . WeatherAPI . Timezone != "UTC" || batch . OutputDir != filepath . Join ( runner . WorkingDir , "reports" ) || batch . WorkingDir != runner . WorkingDir || batch . LLMDebugDir != "/safe/debug" || ! batchOpts . Quiet {
2026-07-31 17:22:19 +00:00
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 { } )
2026-08-01 19:33:12 +00:00
runner . WorkingDir = t . TempDir ( )
2026-07-31 17:22:19 +00:00
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 { } )
2026-08-01 19:33:12 +00:00
runner . WorkingDir = t . TempDir ( )
2026-07-31 17:22:19 +00:00
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 { } )
2026-08-01 19:33:12 +00:00
runner . WorkingDir = t . TempDir ( )
2026-07-31 17:22:19 +00:00
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 } )
2026-08-01 19:33:12 +00:00
runner . WorkingDir = t . TempDir ( )
2026-07-31 17:22:19 +00:00
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" } )
2026-08-01 19:33:12 +00:00
runner . WorkingDir = t . TempDir ( )
2026-07-31 17:22:19 +00:00
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 { } )
2026-08-01 19:33:12 +00:00
runner . WorkingDir = t . TempDir ( )
2026-07-31 17:22:19 +00:00
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 ) , 0 o600 ) ; 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 ) , 0 o755 ) ; err != nil {
t . Fatalf ( "create historical fixture directory: %v" , err )
}
if err := os . MkdirAll ( dir , 0 o755 ) ; 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 , 0 o600 ) ; 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 , 0 o600 ) ; 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 , 0 o600 ) ; err != nil {
t . Fatalf ( "write historical metadata: %v" , err )
}
return runID
}