2026-08-01 19:52:22 +00:00
package app
import (
"context"
"encoding/json"
"errors"
2026-08-25 23:46:21 +00:00
"net/http"
2026-08-01 19:52:22 +00:00
"os"
"path/filepath"
2026-08-13 02:33:32 +00:00
"strings"
2026-08-02 05:50:29 +00:00
"sync"
2026-08-01 19:52:22 +00:00
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
2026-08-13 13:52:16 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
2026-08-01 19:52:22 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
2026-08-13 13:52:16 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
2026-08-01 19:52:22 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type generationCollector struct {
2026-08-01 21:51:42 +00:00
bundle * weatherdata . Bundle
err error
called bool
2026-08-02 05:30:52 +00:00
calls int
2026-08-01 21:51:42 +00:00
beforeRun func ( )
2026-08-01 19:52:22 +00:00
}
2026-08-13 02:40:29 +00:00
type publicationGateContext struct {
context . Context
2026-08-13 03:02:59 +00:00
err error
checks int
afterChecks int
2026-08-13 02:40:29 +00:00
}
func ( c * publicationGateContext ) Err ( ) error {
c . checks ++
2026-08-13 03:02:59 +00:00
afterChecks := c . afterChecks
if afterChecks == 0 {
afterChecks = 2
}
if c . checks >= afterChecks {
2026-08-13 02:40:29 +00:00
return c . err
}
return nil
}
2026-08-01 20:06:40 +00:00
func ( c * generationCollector ) Run ( context . Context , collect . Request ) ( * collect . Result , error ) {
2026-08-01 21:51:42 +00:00
if c . beforeRun != nil {
c . beforeRun ( )
}
2026-08-01 20:06:40 +00:00
c . called = true
2026-08-02 05:30:52 +00:00
c . calls ++
2026-08-01 19:52:22 +00:00
return & collect . Result { Bundle : c . bundle } , c . err
}
2026-08-01 20:06:40 +00:00
type generationExecutor struct {
2026-08-02 14:24:24 +00:00
called bool
executeCalls int
promptInspections int
profileInspections int
inspectErr error
profileInspectErrors map [ string ] error
executeErr error
executeErrors map [ string ] error
beforeExecute func ( promptexec . ExecuteRequest )
cancelBeforeReturn context . CancelFunc
validation promptexec . ValidationStatus
2026-08-25 19:46:56 +00:00
repairAttempts int
2026-08-13 03:26:11 +00:00
validations map [ string ] promptexec . ValidationStatus
2026-08-02 14:24:24 +00:00
rawOutput [ ] byte
2026-08-13 03:26:11 +00:00
waitForCancellation map [ string ] bool
2026-08-02 14:24:24 +00:00
failedPrompt string
2026-08-13 02:30:40 +00:00
skipPreparation bool
preparationCalls int
prepare func ( * promptexec . Preparation )
complete func ( * promptexec . Execution )
2026-08-01 20:06:40 +00:00
}
2026-08-01 19:52:22 +00:00
2026-08-02 05:50:29 +00:00
var generationExecutorMu sync . Mutex
2026-08-01 21:51:42 +00:00
func ( e * generationExecutor ) InspectPrompt ( _ context . Context , id , version string ) ( promptexec . PromptInspection , error ) {
2026-08-02 05:50:29 +00:00
generationExecutorMu . Lock ( )
defer generationExecutorMu . Unlock ( )
2026-08-01 21:51:42 +00:00
e . promptInspections ++
2026-08-01 20:06:40 +00:00
if e . inspectErr != nil {
return promptexec . PromptInspection { } , e . inspectErr
}
definition := generationDefinitionForPrompt ( id )
2026-08-25 19:49:57 +00:00
return promptexec . PromptInspection { PromptID : id , PromptVersion : version , PromptHash : generationPromptHash , DefaultProfileID : "fixture" , Inputs : [ ] promptexec . InputDefinition { { Name : "data_package" , Required : true , ContentType : "application/yaml" } } , Output : promptexec . OutputContract { Format : "json" , ValidationMode : "json_schema" , SchemaPath : definition . GeneratedTextSchemaID + ".generated_text.schema.json" , RepairAttempts : definition . GeneratedTextRepairAttempts } } , nil
2026-08-01 19:52:22 +00:00
}
2026-08-02 05:50:29 +00:00
func ( e * generationExecutor ) InspectProfile ( _ context . Context , id string ) ( promptexec . ProfileInspection , error ) {
generationExecutorMu . Lock ( )
defer generationExecutorMu . Unlock ( )
e . profileInspections ++
2026-08-02 14:24:24 +00:00
if err := e . profileInspectErrors [ id ] ; err != nil {
return promptexec . ProfileInspection { } , err
}
2026-08-01 19:52:22 +00:00
return promptexec . ProfileInspection { ProfileID : id , BackendID : "fixture" , ModelName : "fixture-model" } , nil
}
2026-08-13 03:26:11 +00:00
func ( e * generationExecutor ) Execute ( ctx context . Context , req promptexec . ExecuteRequest , callback promptexec . PreparationCallback ) ( * promptexec . Execution , error ) {
2026-08-01 19:52:22 +00:00
stamp := time . Date ( 2026 , 5 , 29 , 15 , 0 , 0 , 0 , time . UTC )
2026-08-13 02:30:40 +00:00
generationExecutorMu . Lock ( )
skipPreparation := e . skipPreparation
prepare := e . prepare
preparationCalls := e . preparationCalls
generationExecutorMu . Unlock ( )
if ! skipPreparation {
calls := preparationCalls
if calls == 0 {
calls = 1
}
for range calls {
2026-08-25 19:49:57 +00:00
definition := generationDefinitionForPrompt ( req . PromptID )
preparation := promptexec . Preparation { PromptID : req . PromptID , PromptVersion : req . PromptVersion , PromptHash : generationPromptHash , RenderedPromptHash : "rendered-hash" , ProfileID : req . ProfileID , BackendID : "fixture" , ModelName : "fixture-model" , Output : promptexec . OutputContract { Format : "json" , ValidationMode : "json_schema" , SchemaPath : definition . GeneratedTextSchemaID + ".generated_text.schema.json" , RepairAttempts : definition . GeneratedTextRepairAttempts } , StartedAt : stamp , EndedAt : stamp }
2026-08-13 02:30:40 +00:00
if prepare != nil {
prepare ( & preparation )
}
if err := callback ( preparation , nil ) ; err != nil {
return nil , err
}
}
2026-08-01 19:52:22 +00:00
}
2026-08-02 05:50:29 +00:00
generationExecutorMu . Lock ( )
2026-08-01 19:52:22 +00:00
e . called = true
2026-08-02 05:35:59 +00:00
e . executeCalls ++
2026-08-02 05:50:29 +00:00
beforeExecute := e . beforeExecute
profileErr := e . executeErrors [ req . ProfileID ]
executeErr := e . executeErr
2026-08-01 20:06:40 +00:00
status := e . validation
2026-08-25 19:46:56 +00:00
repairAttempts := e . repairAttempts
2026-08-13 03:26:11 +00:00
if profileStatus , ok := e . validations [ req . ProfileID ] ; ok {
status = profileStatus
}
2026-08-02 05:50:29 +00:00
rawOutput := append ( [ ] byte ( nil ) , e . rawOutput ... )
2026-08-13 03:26:11 +00:00
waitForCancellation := e . waitForCancellation [ req . ProfileID ]
2026-08-02 05:50:29 +00:00
failedPrompt := e . failedPrompt
cancelBeforeReturn := e . cancelBeforeReturn
2026-08-13 02:30:40 +00:00
complete := e . complete
2026-08-02 05:50:29 +00:00
generationExecutorMu . Unlock ( )
if beforeExecute != nil {
beforeExecute ( req )
}
2026-08-13 03:26:11 +00:00
if waitForCancellation {
<- ctx . Done ( )
return nil , ctx . Err ( )
}
2026-08-02 05:50:29 +00:00
if profileErr != nil {
return nil , profileErr
}
if executeErr != nil {
return nil , executeErr
}
2026-08-01 20:06:40 +00:00
if status == "" {
status = promptexec . ValidationPassed
}
2026-08-02 05:50:29 +00:00
if failedPrompt == req . PromptID {
2026-08-01 20:06:40 +00:00
status = promptexec . ValidationFailed
}
if rawOutput == nil {
rawOutput = [ ] byte ( ` { "summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"precipitation_timing":"Rain is most likely during the afternoon."} ` )
}
2026-08-02 05:50:29 +00:00
if cancelBeforeReturn != nil {
cancelBeforeReturn ( )
2026-08-01 21:49:18 +00:00
}
2026-08-25 19:46:56 +00:00
execution := & promptexec . Execution { RunID : "provider-run" , PromptID : req . PromptID , PromptVersion : req . PromptVersion , PromptHash : generationPromptHash , RenderedPromptHash : "rendered-hash" , ProfileID : req . ProfileID , BackendID : "fixture" , ModelName : "fixture-model" , StartedAt : stamp , EndedAt : stamp , RawOutput : rawOutput , Validation : promptexec . NewValidation ( status , "json_schema" , generationDefinitionForPrompt ( req . PromptID ) . GeneratedTextSchemaID + ".generated_text.schema.json" , repairAttempts , nil ) }
2026-08-13 02:30:40 +00:00
if complete != nil {
complete ( execution )
}
return execution , nil
2026-08-01 20:06:40 +00:00
}
2026-08-02 05:50:29 +00:00
const generationPromptHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
2026-08-01 20:06:40 +00:00
func generationDefinitionForPrompt ( promptID string ) report . Definition {
for _ , definition := range report . DefaultRegistry ( ) . All ( ) {
if definition . PromptID == promptID {
return definition
}
}
panic ( "unknown fixture prompt " + promptID )
2026-08-01 19:52:22 +00:00
}
func TestGenerateDetailedPublishesOnlySelectedOutput ( t * testing . T ) {
cfg := config . Defaults ( )
cfg . WeatherAPI . Timezone , cfg . Location . ID = "America/Chicago" , "home"
bundle := generationBundle ( t )
executor := & generationExecutor { }
2026-08-02 05:30:52 +00:00
collector := & generationCollector { bundle : & bundle }
2026-08-01 19:52:22 +00:00
workingDir := t . TempDir ( )
2026-08-02 05:30:52 +00:00
result , err := GenerateDetailed ( context . Background ( ) , GenerateRequest { Config : cfg , Report : ReportDaily , Date : generationTime ( "2026-05-29T12:00:00-05:00" ) , Now : generationTime ( "2026-05-29T08:30:00-05:00" ) , WorkingDir : workingDir , Collector : collector , Executor : executor } )
2026-08-01 19:52:22 +00:00
if err != nil {
t . Fatalf ( "GenerateDetailed() error = %v" , err )
}
2026-08-02 05:35:59 +00:00
if ! executor . called || executor . executeCalls != 1 || collector . calls != 1 || result . OutputPath != filepath . Join ( workingDir , "daily-2026-05-29.md" ) || result . ValidationStatus != promptexec . ValidationPassed || result . ProfileID == "" || result . BackendID == "" || result . ModelName == "" {
2026-08-01 19:52:22 +00:00
t . Fatalf ( "result = %#v" , result )
}
2026-08-01 20:06:40 +00:00
if result . LLMDebugPath != "" {
t . Fatalf ( "unexpected debug output = %q" , result . LLMDebugPath )
}
2026-08-01 19:52:22 +00:00
if _ , err := os . Stat ( filepath . Join ( workingDir , "workspace" ) ) ; ! os . IsNotExist ( err ) {
2026-08-01 20:06:40 +00:00
t . Fatalf ( "unexpected default state directory: %v" , err )
2026-08-01 19:52:22 +00:00
}
data , err := os . ReadFile ( result . OutputPath )
if err != nil || len ( data ) == 0 {
t . Fatalf ( "output = %q, error = %v" , data , err )
}
}
2026-08-02 01:38:06 +00:00
func TestGenerateDetailedUsesConfiguredOutputDirectory ( t * testing . T ) {
tests := [ ] struct {
name string
directory func ( t * testing . T , workingDir string ) string
wantDir func ( t * testing . T , workingDir string , configuredDir string ) string
} {
{
name : "absolute directory" ,
directory : func ( t * testing . T , _ string ) string {
return filepath . Join ( t . TempDir ( ) , "reports" )
} ,
wantDir : func ( _ * testing . T , _ string , configuredDir string ) string {
return configuredDir
} ,
} ,
{
name : "relative directory" ,
directory : func ( _ * testing . T , _ string ) string {
return "configured/../reports"
} ,
wantDir : func ( _ * testing . T , workingDir string , _ string ) string {
return filepath . Join ( workingDir , "reports" )
} ,
} ,
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
workingDir := t . TempDir ( )
configuredDir := tt . directory ( t , workingDir )
cfg := generationDistributorConfig ( )
cfg . Output . Directory = configuredDir
bundle := generationBundle ( t )
notifier := & generationNotifier { }
result , err := GenerateDetailed ( context . Background ( ) , GenerateRequest {
Config : cfg , Report : ReportDaily ,
Date : generationTime ( "2026-05-29T12:00:00-05:00" ) , Now : generationTime ( "2026-05-29T08:30:00-05:00" ) ,
WorkingDir : workingDir , Collector : & generationCollector { bundle : & bundle } , Executor : & generationExecutor { } , Notifier : notifier ,
} )
wantPath := filepath . Join ( tt . wantDir ( t , workingDir , configuredDir ) , "daily-2026-05-29.md" )
if err != nil || result == nil || result . OutputPath != wantPath || notifier . request . ReportPath != wantPath {
t . Fatalf ( "GenerateDetailed() result/error/notification = %#v/%v/%#v" , result , err , notifier . request )
}
if info , statErr := os . Stat ( filepath . Dir ( wantPath ) ) ; statErr != nil || ! info . IsDir ( ) {
t . Fatalf ( "configured output directory info/error = %#v/%v" , info , statErr )
}
if _ , statErr := os . Stat ( wantPath ) ; statErr != nil {
t . Fatalf ( "output %q: %v" , wantPath , statErr )
}
} )
}
}
func TestGenerateDetailedExplicitOutputPathIgnoresConfiguredDirectory ( t * testing . T ) {
configuredPath := filepath . Join ( t . TempDir ( ) , "not-a-directory" )
if err := os . WriteFile ( configuredPath , [ ] byte ( "not a directory" ) , 0 o600 ) ; err != nil {
t . Fatal ( err )
}
explicitPath := filepath . Join ( t . TempDir ( ) , "explicit.md" )
cfg := generationConfig ( )
cfg . Output . Directory = configuredPath
bundle := generationBundle ( t )
result , err := GenerateDetailed ( context . Background ( ) , GenerateRequest {
Config : cfg , Report : ReportDaily ,
Date : generationTime ( "2026-05-29T12:00:00-05:00" ) , Now : generationTime ( "2026-05-29T08:30:00-05:00" ) ,
WorkingDir : t . TempDir ( ) , OutputPath : explicitPath , Collector : & generationCollector { bundle : & bundle } , Executor : & generationExecutor { } ,
} )
if err != nil || result == nil || result . OutputPath != explicitPath {
t . Fatalf ( "GenerateDetailed() result/error = %#v/%v" , result , err )
}
if _ , statErr := os . Stat ( explicitPath ) ; statErr != nil {
t . Fatalf ( "explicit output %q: %v" , explicitPath , statErr )
}
}
func TestGenerateDetailedRejectsConfiguredNonDirectoryBeforeWork ( t * testing . T ) {
configuredPath := filepath . Join ( t . TempDir ( ) , "not-a-directory" )
if err := os . WriteFile ( configuredPath , [ ] byte ( "not a directory" ) , 0 o600 ) ; err != nil {
t . Fatal ( err )
}
cfg := generationDistributorConfig ( )
cfg . Output . Directory = configuredPath
bundle := generationBundle ( t )
collector := & generationCollector { bundle : & bundle }
executor := & generationExecutor { }
notifier := & generationNotifier { }
result , err := GenerateDetailed ( context . Background ( ) , GenerateRequest {
Config : cfg , Report : ReportDaily ,
Date : generationTime ( "2026-05-29T12:00:00-05:00" ) , Now : generationTime ( "2026-05-29T08:30:00-05:00" ) ,
WorkingDir : t . TempDir ( ) , Collector : collector , Executor : executor , Notifier : notifier ,
} )
if err == nil || result == nil || collector . called || executor . promptInspections != 0 || executor . called || notifier . calls != 0 {
t . Fatalf ( "GenerateDetailed() result/error/collector/executor/notifier = %#v/%v/%t/%#v/%#v" , result , err , collector . called , executor , notifier )
}
if data , readErr := os . ReadFile ( configuredPath ) ; readErr != nil || string ( data ) != "not a directory" {
t . Fatalf ( "configured path = %q, error = %v" , data , readErr )
}
}
2026-08-13 02:33:32 +00:00
func TestGenerateDetailedRejectsOverlongOutputBeforeWork ( t * testing . T ) {
missingDirectory := filepath . Join ( t . TempDir ( ) , "missing" )
outputPath := filepath . Join ( missingDirectory , strings . Repeat ( "a" , 253 ) + ".md" )
bundle := generationBundle ( t )
collector := & generationCollector { bundle : & bundle }
executor := & generationExecutor { }
result , err := GenerateDetailed ( context . Background ( ) , GenerateRequest {
Config : generationConfig ( ) , Report : ReportDaily ,
Date : generationTime ( "2026-05-29T12:00:00-05:00" ) , Now : generationTime ( "2026-05-29T08:30:00-05:00" ) ,
WorkingDir : t . TempDir ( ) , OutputPath : outputPath , Collector : collector , Executor : executor ,
} )
if err == nil || result == nil || collector . called || executor . promptInspections != 0 || executor . called {
t . Fatalf ( "GenerateDetailed() result/error/collector/executor = %#v/%v/%t/%#v" , result , err , collector . called , executor )
}
if _ , statErr := os . Stat ( missingDirectory ) ; ! os . IsNotExist ( statErr ) {
t . Fatalf ( "missing output directory exists after preflight failure: %v" , statErr )
}
}
2026-08-13 02:44:21 +00:00
func TestGenerateDetailedRejectsUnsupportedDistributorEndpointBeforeWork ( t * testing . T ) {
outputPath := filepath . Join ( t . TempDir ( ) , "daily.md" )
cfg := generationDistributorConfig ( )
cfg . Notify . Distributor . Endpoint = "ftp://distributor.example.test"
bundle := generationBundle ( t )
collector := & generationCollector { bundle : & bundle }
executor := & generationExecutor { }
notifier := & generationNotifier { }
result , err := GenerateDetailed ( context . Background ( ) , GenerateRequest {
Config : cfg , Report : ReportDaily ,
Date : generationTime ( "2026-05-29T12:00:00-05:00" ) , Now : generationTime ( "2026-05-29T08:30:00-05:00" ) ,
WorkingDir : t . TempDir ( ) , OutputPath : outputPath , Collector : collector , Executor : executor , Notifier : notifier ,
} )
if err == nil || result == nil || collector . called || executor . promptInspections != 0 || executor . called || notifier . calls != 0 {
t . Fatalf ( "GenerateDetailed() result/error/collector/executor/notifier = %#v/%v/%t/%#v/%#v" , result , err , collector . called , executor , notifier )
}
if _ , statErr := os . Stat ( outputPath ) ; ! os . IsNotExist ( statErr ) {
t . Fatalf ( "output exists after endpoint preflight failure: %v" , statErr )
}
}
2026-08-01 19:52:22 +00:00
func TestGenerateDetailedReturnsResolvedResultWhenCollectionFails ( t * testing . T ) {
cfg := config . Defaults ( )
cfg . WeatherAPI . Timezone , cfg . Location . ID = "America/Chicago" , "home"
collectionErr := errors . New ( "weather source unavailable" )
result , err := GenerateDetailed ( context . Background ( ) , GenerateRequest {
Config : cfg , Report : ReportDaily , Date : generationTime ( "2026-05-29T12:00:00-05:00" ) , Now : generationTime ( "2026-05-29T08:30:00-05:00" ) ,
2026-08-01 20:06:40 +00:00
WorkingDir : t . TempDir ( ) , Collector : & generationCollector { err : collectionErr } , Executor : & generationExecutor { } ,
2026-08-01 19:52:22 +00:00
} )
if ! errors . Is ( err , collectionErr ) {
t . Fatalf ( "GenerateDetailed() error = %v, want %v" , err , collectionErr )
}
if result == nil || result . ReportID != report . Daily || result . RunID == "" || result . ProfileID != "fixture" || result . BackendID != "fixture" || result . ModelName != "fixture-model" || result . OutputPath != "" {
t . Fatalf ( "result = %#v" , result )
}
}
2026-08-01 20:06:40 +00:00
func TestGenerateDetailedInspectsPromptBeforeCollectingWeather ( t * testing . T ) {
cfg := generationConfig ( )
inspectionErr := errors . New ( "profile is invalid" )
collector := & generationCollector { bundle : generationBundlePointer ( t ) }
result , err := GenerateDetailed ( context . Background ( ) , GenerateRequest { Config : cfg , Report : ReportDaily , Date : generationTime ( "2026-05-29T12:00:00-05:00" ) , Now : generationTime ( "2026-05-29T08:30:00-05:00" ) , WorkingDir : t . TempDir ( ) , Collector : collector , Executor : & generationExecutor { inspectErr : inspectionErr } } )
if ! errors . Is ( err , inspectionErr ) || collector . called || result == nil {
t . Fatalf ( "GenerateDetailed() result/error/collector-called = %#v/%v/%t" , result , err , collector . called )
}
}
func TestGenerateDetailedPreservesDestinationBeforePublish ( t * testing . T ) {
for _ , scenario := range [ ] struct {
name string
executor generationExecutor
} {
{ name : "generation" , executor : generationExecutor { executeErr : errors . New ( "provider unavailable" ) } } ,
{ name : "render" , executor : generationExecutor { rawOutput : [ ] byte ( ` { "summary":""} ` ) } } ,
} {
t . Run ( scenario . name , func ( t * testing . T ) {
outputPath := filepath . Join ( t . TempDir ( ) , "daily.md" )
if err := os . WriteFile ( outputPath , [ ] byte ( "previous report" ) , 0 o600 ) ; err != nil {
t . Fatal ( err )
}
bundle := generationBundle ( t )
2026-08-01 21:49:18 +00:00
result , err := GenerateDetailed ( context . Background ( ) , GenerateRequest { Config : generationConfig ( ) , Report : ReportDaily , Date : generationTime ( "2026-05-29T12:00:00-05:00" ) , Now : generationTime ( "2026-05-29T08:30:00-05:00" ) , WorkingDir : t . TempDir ( ) , OutputPath : outputPath , Collector : & generationCollector { bundle : & bundle } , Executor : & scenario . executor } )
2026-08-01 20:06:40 +00:00
data , readErr := os . ReadFile ( outputPath )
if err == nil || result == nil || readErr != nil || string ( data ) != "previous report" {
t . Fatalf ( "GenerateDetailed() result/error/output = %#v/%v/%q (%v)" , result , err , data , readErr )
}
} )
}
}
2026-08-01 21:49:18 +00:00
func TestGenerateDetailedPreservesDestinationWhenContextCancelsBeforePublication ( t * testing . T ) {
outputPath := filepath . Join ( t . TempDir ( ) , "daily.md" )
const previousReport = "previous report"
if err := os . WriteFile ( outputPath , [ ] byte ( previousReport ) , 0 o600 ) ; err != nil {
t . Fatal ( err )
}
ctx , cancel := context . WithCancel ( context . Background ( ) )
bundle := generationBundle ( t )
result , err := GenerateDetailed ( ctx , GenerateRequest {
Config : generationConfig ( ) , Report : ReportDaily ,
Date : generationTime ( "2026-05-29T12:00:00-05:00" ) , Now : generationTime ( "2026-05-29T08:30:00-05:00" ) ,
WorkingDir : t . TempDir ( ) , OutputPath : outputPath , Collector : & generationCollector { bundle : & bundle } , Executor : & generationExecutor { cancelBeforeReturn : cancel } ,
} )
data , readErr := os . ReadFile ( outputPath )
if ! errors . Is ( err , context . Canceled ) || promptexec . CategoryOf ( err ) != promptexec . Canceled || result == nil || result . OutputPath != "" || readErr != nil || string ( data ) != previousReport {
t . Fatalf ( "GenerateDetailed() result/error/output = %#v/%v/%q (%v)" , result , err , data , readErr )
}
}
func TestGenerateDetailedPreservesDestinationWhenContextDeadlineExpiresBeforePublication ( t * testing . T ) {
outputPath := filepath . Join ( t . TempDir ( ) , "daily.md" )
const previousReport = "previous report"
if err := os . WriteFile ( outputPath , [ ] byte ( previousReport ) , 0 o600 ) ; err != nil {
t . Fatal ( err )
}
ctx , cancel := context . WithDeadline ( context . Background ( ) , time . Unix ( 0 , 0 ) )
defer cancel ( )
bundle := generationBundle ( t )
result , err := GenerateDetailed ( ctx , GenerateRequest {
Config : generationConfig ( ) , Report : ReportDaily ,
Date : generationTime ( "2026-05-29T12:00:00-05:00" ) , Now : generationTime ( "2026-05-29T08:30:00-05:00" ) ,
WorkingDir : t . TempDir ( ) , OutputPath : outputPath , Collector : & generationCollector { bundle : & bundle } , Executor : & generationExecutor { } ,
} )
data , readErr := os . ReadFile ( outputPath )
if ! errors . Is ( err , context . DeadlineExceeded ) || promptexec . CategoryOf ( err ) != promptexec . DeadlineExceeded || result == nil || result . OutputPath != "" || readErr != nil || string ( data ) != previousReport {
t . Fatalf ( "GenerateDetailed() result/error/output = %#v/%v/%q (%v)" , result , err , data , readErr )
}
}
2026-08-13 02:40:29 +00:00
func TestGenerateDetailedPreservesDestinationWhenContextChangesDuringPublication ( t * testing . T ) {
for _ , tt := range [ ] struct {
name string
err error
category promptexec . ErrorCategory
} {
{ name : "canceled" , err : context . Canceled , category : promptexec . Canceled } ,
{ name : "deadline" , err : context . DeadlineExceeded , category : promptexec . DeadlineExceeded } ,
} {
t . Run ( tt . name , func ( t * testing . T ) {
outputPath := filepath . Join ( t . TempDir ( ) , "daily.md" )
const previousReport = "previous report"
if err := os . WriteFile ( outputPath , [ ] byte ( previousReport ) , 0 o600 ) ; err != nil {
t . Fatal ( err )
}
ctx := & publicationGateContext { Context : context . Background ( ) , err : tt . err }
cfg := generationConfig ( )
cfg . Notify . Distributor . Enabled = true
cfg . Notify . Distributor . PipelineIDTemplate = "weather"
bundle := generationBundle ( t )
notifier := & generationNotifier { }
result , err := GenerateDetailed ( ctx , GenerateRequest {
Config : cfg , Report : ReportDaily ,
Date : generationTime ( "2026-05-29T12:00:00-05:00" ) , Now : generationTime ( "2026-05-29T08:30:00-05:00" ) ,
WorkingDir : t . TempDir ( ) , OutputPath : outputPath , Collector : & generationCollector { bundle : & bundle } , Executor : & generationExecutor { } , Notifier : notifier ,
} )
data , readErr := os . ReadFile ( outputPath )
matches , globErr := filepath . Glob ( filepath . Join ( filepath . Dir ( outputPath ) , ".weatherreporter-*.tmp" ) )
if ! errors . Is ( err , tt . err ) || promptexec . CategoryOf ( err ) != tt . category || result == nil || result . OutputPath != "" || notifier . calls != 0 || readErr != nil || string ( data ) != previousReport || globErr != nil || len ( matches ) != 0 {
t . Fatalf ( "GenerateDetailed() result/error/output/notification/temp = %#v/%v/%q/%#v/%v/%v" , result , err , data , notifier , matches , globErr )
}
} )
}
}
2026-08-01 20:06:40 +00:00
func TestGenerateDetailedRetainsPublishedOutputWhenNotificationFails ( t * testing . T ) {
cfg := generationConfig ( )
cfg . Notify . Distributor . Enabled = true
cfg . Notify . Distributor . PipelineIDTemplate = "weather"
bundle := generationBundle ( t )
outputPath := filepath . Join ( t . TempDir ( ) , "daily.md" )
notifier := & generationNotifier { err : errors . New ( "distributor unavailable" ) }
result , err := GenerateDetailed ( context . Background ( ) , GenerateRequest { Config : cfg , Report : ReportDaily , Date : generationTime ( "2026-05-29T12:00:00-05:00" ) , Now : generationTime ( "2026-05-29T08:30:00-05:00" ) , WorkingDir : t . TempDir ( ) , OutputPath : outputPath , Collector : & generationCollector { bundle : & bundle } , Executor : & generationExecutor { } , Notifier : notifier } )
if err == nil || result == nil || result . OutputPath != outputPath || notifier . request . ReportPath != outputPath || len ( notifier . request . BundlePaths ) == 0 {
t . Fatalf ( "GenerateDetailed() result/error/request = %#v/%v/%#v" , result , err , notifier . request )
}
if data , readErr := os . ReadFile ( outputPath ) ; readErr != nil || len ( data ) == 0 {
t . Fatalf ( "published output = %q, error = %v" , data , readErr )
}
}
func TestGenerateDetailedDoesNotReplaceDirectoryOutput ( t * testing . T ) {
bundle := generationBundle ( t )
outputPath := filepath . Join ( t . TempDir ( ) , "daily.md" )
if err := os . Mkdir ( outputPath , 0 o700 ) ; err != nil {
t . Fatal ( err )
}
2026-08-13 02:40:29 +00:00
collector := & generationCollector { bundle : & bundle }
executor := & generationExecutor { }
notifier := & generationNotifier { }
result , err := GenerateDetailed ( context . Background ( ) , GenerateRequest { Config : generationConfig ( ) , Report : ReportDaily , Date : generationTime ( "2026-05-29T12:00:00-05:00" ) , Now : generationTime ( "2026-05-29T08:30:00-05:00" ) , WorkingDir : t . TempDir ( ) , OutputPath : outputPath , Collector : collector , Executor : executor , Notifier : notifier } )
2026-08-01 20:06:40 +00:00
info , statErr := os . Stat ( outputPath )
2026-08-13 02:40:29 +00:00
if err == nil || result == nil || statErr != nil || ! info . IsDir ( ) || collector . called || executor . promptInspections != 0 || executor . called || notifier . calls != 0 {
t . Fatalf ( "GenerateDetailed() result/error/output-info/collector/executor/notifier = %#v/%v/%#v (%v)/%t/%#v/%#v" , result , err , info , statErr , collector . called , executor , notifier )
}
}
func TestGenerateDetailedDoesNotReplaceSymbolicLinkOutput ( t * testing . T ) {
dir := t . TempDir ( )
backing := filepath . Join ( dir , "backing.md" )
if err := os . WriteFile ( backing , [ ] byte ( "previous report" ) , 0 o600 ) ; err != nil {
t . Fatal ( err )
}
outputPath := filepath . Join ( dir , "daily.md" )
2026-08-13 13:52:16 +00:00
testutil . RequireSymlink ( t , backing , outputPath )
2026-08-13 02:40:29 +00:00
bundle := generationBundle ( t )
collector := & generationCollector { bundle : & bundle }
executor := & generationExecutor { }
notifier := & generationNotifier { }
result , err := GenerateDetailed ( context . Background ( ) , GenerateRequest { Config : generationConfig ( ) , Report : ReportDaily , Date : generationTime ( "2026-05-29T12:00:00-05:00" ) , Now : generationTime ( "2026-05-29T08:30:00-05:00" ) , WorkingDir : t . TempDir ( ) , OutputPath : outputPath , Collector : collector , Executor : executor , Notifier : notifier } )
info , statErr := os . Lstat ( outputPath )
data , readErr := os . ReadFile ( backing )
if err == nil || result == nil || statErr != nil || info . Mode ( ) & os . ModeSymlink == 0 || readErr != nil || string ( data ) != "previous report" || collector . called || executor . promptInspections != 0 || executor . called || notifier . calls != 0 {
t . Fatalf ( "GenerateDetailed() result/error/output/backing/collector/executor/notifier = %#v/%v/%#v (%v)/%q (%v)/%t/%#v/%#v" , result , err , info , statErr , data , readErr , collector . called , executor , notifier )
2026-08-01 20:06:40 +00:00
}
}
2026-08-02 05:35:59 +00:00
func TestGenerateDetailedWritesRequestedPromptDebugArtifacts ( t * testing . T ) {
bundle := generationBundle ( t )
debugRoot := t . TempDir ( )
result , err := GenerateDetailed ( context . Background ( ) , GenerateRequest {
Config : generationConfig ( ) , Report : ReportDaily ,
Date : generationTime ( "2026-05-29T12:00:00-05:00" ) , Now : generationTime ( "2026-05-29T08:30:00-05:00" ) ,
WorkingDir : t . TempDir ( ) , LLMDebugDir : debugRoot , Collector : & generationCollector { bundle : & bundle } , Executor : & generationExecutor { } ,
} )
2026-08-13 13:52:16 +00:00
if errors . Is ( err , promptdebug . ErrSecureCaptureUnsupported ) {
t . Skipf ( "secure prompt debug capture is unavailable: %v" , err )
}
2026-08-02 05:35:59 +00:00
if err != nil || result == nil || result . LLMDebugPath == "" {
t . Fatalf ( "GenerateDetailed() result/error = %#v/%v" , result , err )
}
for _ , name := range [ ] string { "preparation.json" , "execution.json" } {
if _ , statErr := os . Stat ( filepath . Join ( result . LLMDebugPath , name ) ) ; statErr != nil {
t . Fatalf ( "debug artifact %q: %v" , name , statErr )
}
}
}
2026-08-25 23:46:21 +00:00
func TestGenerateDetailedCapturesProviderFailureOnlyInDebugArtifacts ( t * testing . T ) {
bundle := generationBundle ( t )
debugRoot := t . TempDir ( )
const marker = "provider-private-generation-marker"
result , err := GenerateDetailed ( context . Background ( ) , GenerateRequest {
Config : generationConfig ( ) , Report : ReportDaily ,
Date : generationTime ( "2026-05-29T12:00:00-05:00" ) , Now : generationTime ( "2026-05-29T08:30:00-05:00" ) ,
WorkingDir : t . TempDir ( ) , LLMDebugDir : debugRoot , Collector : & generationCollector { bundle : & bundle } ,
Executor : & generationExecutor { executeErr : promptexec . NewGenerationError ( http . StatusTooManyRequests , "rate_limit" , "provider_error" , marker , nil ) } ,
} )
if errors . Is ( err , promptdebug . ErrSecureCaptureUnsupported ) {
t . Skipf ( "secure prompt debug capture is unavailable: %v" , err )
}
if err == nil || result == nil || result . LLMDebugPath == "" || promptexec . CategoryOf ( err ) != promptexec . Generation || ! strings . Contains ( err . Error ( ) , "HTTP 429" ) || strings . Contains ( err . Error ( ) , marker ) || result . OutputPath != "" {
t . Fatalf ( "GenerateDetailed() result/error = %#v/%v" , result , err )
}
for _ , name := range [ ] string { "preparation.json" , "failure.json" } {
if _ , statErr := os . Stat ( filepath . Join ( result . LLMDebugPath , name ) ) ; statErr != nil {
t . Fatalf ( "debug artifact %q: %v" , name , statErr )
}
}
data , readErr := os . ReadFile ( filepath . Join ( result . LLMDebugPath , "failure.json" ) )
if readErr != nil || ! strings . Contains ( string ( data ) , marker ) {
t . Fatalf ( "failure artifact = %q, error = %v" , data , readErr )
}
}
func TestGenerateDetailedPreservesProviderFailureWhenFailureDebugWriteFails ( t * testing . T ) {
bundle := generationBundle ( t )
debugRoot := t . TempDir ( )
const marker = "provider-private-write-failure-marker"
var setupErr error
executor := & generationExecutor {
executeErr : promptexec . NewGenerationError ( http . StatusServiceUnavailable , "unavailable" , "provider_error" , marker , nil ) ,
beforeExecute : func ( promptexec . ExecuteRequest ) {
setupErr = filepath . Walk ( debugRoot , func ( path string , info os . FileInfo , err error ) error {
if err != nil {
return err
}
if info . Name ( ) == "preparation.json" {
return os . Mkdir ( filepath . Join ( filepath . Dir ( path ) , "failure.json" ) , 0 o700 )
}
return nil
} )
} ,
}
result , err := GenerateDetailed ( context . Background ( ) , GenerateRequest {
Config : generationConfig ( ) , Report : ReportDaily ,
Date : generationTime ( "2026-05-29T12:00:00-05:00" ) , Now : generationTime ( "2026-05-29T08:30:00-05:00" ) ,
WorkingDir : t . TempDir ( ) , LLMDebugDir : debugRoot , Collector : & generationCollector { bundle : & bundle } , Executor : executor ,
} )
if errors . Is ( err , promptdebug . ErrSecureCaptureUnsupported ) {
t . Skipf ( "secure prompt debug capture is unavailable: %v" , err )
}
var generationError * promptexec . GenerationError
if setupErr != nil || err == nil || result == nil || result . OutputPath != "" || promptexec . CategoryOf ( err ) != promptexec . Generation || ! errors . As ( err , & generationError ) || generationError . StatusCode ( ) != http . StatusServiceUnavailable || ! strings . Contains ( err . Error ( ) , "HTTP 503" ) || strings . Contains ( err . Error ( ) , marker ) {
t . Fatalf ( "GenerateDetailed() setup/result/error = %v/%#v/%v" , setupErr , result , err )
}
}
2026-08-01 20:06:40 +00:00
func generationConfig ( ) config . Config {
cfg := config . Defaults ( )
cfg . WeatherAPI . Timezone , cfg . Location . ID = "America/Chicago" , "home"
return cfg
}
func generationBundlePointer ( t * testing . T ) * weatherdata . Bundle {
bundle := generationBundle ( t )
return & bundle
}
type generationNotifier struct {
err error
2026-08-01 21:54:53 +00:00
batchErr error
2026-08-02 01:38:06 +00:00
calls int
2026-08-01 20:06:40 +00:00
request NotificationRequest
batchRequest batchNotificationRequest
batchCalls int
}
func ( n * generationNotifier ) Notify ( _ context . Context , request NotificationRequest ) ( * NotificationResult , error ) {
2026-08-02 01:38:06 +00:00
n . calls ++
2026-08-01 20:06:40 +00:00
n . request = request
2026-08-02 01:38:06 +00:00
if n . err != nil {
return nil , n . err
}
return & NotificationResult { Status : "succeeded" } , nil
2026-08-01 20:06:40 +00:00
}
func ( n * generationNotifier ) NotifyBatch ( _ context . Context , request batchNotificationRequest ) ( * NotificationResult , error ) {
n . batchCalls ++
n . batchRequest = request
for _ , file := range request . Files {
if _ , err := os . Stat ( file . SourcePath ) ; err != nil {
return nil , err
}
}
2026-08-01 21:54:53 +00:00
return & NotificationResult { Status : "succeeded" , PipelineID : request . PipelineID , BundleID : request . BundleID } , n . batchErr
2026-08-01 20:06:40 +00:00
}
2026-08-01 19:52:22 +00:00
func generationBundle ( t * testing . T ) weatherdata . Bundle {
t . Helper ( )
data , err := os . ReadFile ( filepath . Join ( ".." , "forecast" , "testdata" , "daily_bundle.json" ) )
if err != nil {
t . Fatalf ( "read bundle fixture: %v" , err )
}
var bundle weatherdata . Bundle
if err := json . Unmarshal ( data , & bundle ) ; err != nil {
t . Fatalf ( "decode bundle fixture: %v" , err )
}
return bundle
}
func generationTime ( value string ) time . Time {
parsed , _ := time . Parse ( time . RFC3339 , value )
return parsed
}
var _ promptexec . Executor = ( * generationExecutor ) ( nil )
2026-08-01 20:06:40 +00:00
var _ Collector = ( * generationCollector ) ( nil )
var _ Notifier = ( * generationNotifier ) ( nil )
2026-08-01 19:52:22 +00:00
var _ = report . Daily