362 lines
16 KiB
Go
362 lines
16 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
|
)
|
|
|
|
func TestRunBatchDetailedKeepsSuccessfulOutputAndSkipsNotificationAfterPartialFailure(t *testing.T) {
|
|
bundle := generationBundle(t)
|
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
|
notifier := &generationNotifier{}
|
|
executor := &generationExecutor{failedPrompt: generationDefinitionForPrompt("weather.tomorrow_generated_text").PromptID}
|
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
|
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
|
|
})
|
|
if err != nil || result == nil || result.Total != 2 || result.Succeeded != 1 || result.Failed != 1 || result.Canceled != 0 || result.Notification == nil || result.Notification.Status != "skipped" || notifier.batchCalls != 0 {
|
|
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
|
|
}
|
|
if result.Reports[0].Status != "succeeded" || result.Reports[0].OutputPath == "" || result.Reports[1].Status != "failed" || result.Reports[1].OutputPath != "" {
|
|
t.Fatalf("report results = %#v", result.Reports)
|
|
}
|
|
if data, readErr := os.ReadFile(result.Reports[0].OutputPath); readErr != nil || len(data) == 0 {
|
|
t.Fatalf("successful output = %q, error = %v", data, readErr)
|
|
}
|
|
}
|
|
|
|
func TestRunBatchDetailedStopsAfterReportCancellation(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
bundle := generationBundle(t)
|
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
|
notifier := &generationNotifier{}
|
|
executor := &generationExecutor{cancelBeforeReturn: cancel}
|
|
|
|
result, err := RunBatchDetailed(ctx, BatchRequest{
|
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
|
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
|
|
})
|
|
if !errors.Is(err, context.Canceled) || result == nil || result.Total != 2 || result.Succeeded != 0 || result.Failed != 0 || result.Canceled != 2 || executor.executeCalls != 1 || notifier.batchCalls != 0 || result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "batch canceled" {
|
|
t.Fatalf("RunBatchDetailed() result/error/executor/notifier = %#v/%v/%#v/%#v", result, err, executor, notifier)
|
|
}
|
|
for _, item := range result.Reports {
|
|
if item.Status != "canceled" || item.OutputPath != "" {
|
|
t.Fatalf("canceled report = %#v", item)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunBatchDetailedPreservesIndependentFailureDuringCancellation(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
bundle := generationBundle(t)
|
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
|
notifier := &generationNotifier{}
|
|
executor := &generationExecutor{
|
|
executeErr: errors.New("independent report failure"),
|
|
beforeExecute: func(promptexec.ExecuteRequest) {
|
|
cancel()
|
|
},
|
|
}
|
|
|
|
result, err := RunBatchDetailed(ctx, BatchRequest{
|
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
|
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
|
|
})
|
|
if !errors.Is(err, context.Canceled) || result == nil || result.Total != 2 || result.Succeeded != 0 || result.Failed != 1 || result.Canceled != 1 || notifier.batchCalls != 0 || result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "batch canceled" {
|
|
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
|
|
}
|
|
if result.Reports[0].Status != "failed" || result.Reports[0].Error == "" || result.Reports[1].Status != "canceled" {
|
|
t.Fatalf("report results = %#v", result.Reports)
|
|
}
|
|
}
|
|
|
|
func TestNotifyBatchSkipsCancellationObservedAfterReportsComplete(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
notifier := &generationNotifier{}
|
|
|
|
result := notifyBatch(batchNotificationInput{
|
|
ctx: ctx, cfg: generationDistributorConfig(), batch: BatchMorning,
|
|
runID: "run-id", startedAt: generationTime("2026-05-29T08:30:00-05:00"),
|
|
result: &BatchResult{Total: 1, Succeeded: 1, Reports: []BatchReportResult{{Status: "succeeded"}}},
|
|
notifier: notifier,
|
|
})
|
|
|
|
if result == nil || result.Status != "skipped" || result.Reason != "batch canceled" || notifier.batchCalls != 0 {
|
|
t.Fatalf("notifyBatch() result/notifier = %#v/%#v", result, notifier)
|
|
}
|
|
}
|
|
|
|
func TestRunBatchDetailedRetainsPublishedReportBeforeCancellation(t *testing.T) {
|
|
for _, cause := range []error{context.Canceled, context.DeadlineExceeded} {
|
|
t.Run(cause.Error(), func(t *testing.T) {
|
|
bundle := generationBundle(t)
|
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
|
notifier := &generationNotifier{}
|
|
ctx := &publicationGateContext{Context: context.Background(), err: cause, afterChecks: 4}
|
|
|
|
result, err := RunBatchDetailed(ctx, BatchRequest{
|
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
|
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
|
|
})
|
|
if !errors.Is(err, cause) || result == nil || result.Total != 2 || result.Succeeded != 1 || result.Failed != 0 || result.Canceled != 1 || len(result.Reports) != 2 || result.Reports[0].Status != "succeeded" || result.Reports[0].OutputPath == "" || result.Reports[1].Status != "canceled" || result.Reports[1].OutputPath != "" || notifier.batchCalls != 0 || result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "batch canceled" {
|
|
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
|
|
}
|
|
if _, statErr := os.Stat(result.Reports[0].OutputPath); statErr != nil {
|
|
t.Fatalf("published report %q: %v", result.Reports[0].OutputPath, statErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunBatchPreservesCancellationCause(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
bundle := generationBundle(t)
|
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
|
err := RunBatch(ctx, BatchRequest{
|
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
|
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{cancelBeforeReturn: cancel}, Notifier: &generationNotifier{},
|
|
})
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("RunBatch() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunBatchDetailedNotifiesOnlyAfterAllOutputsExist(t *testing.T) {
|
|
bundle := generationBundle(t)
|
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
|
outputDir := t.TempDir()
|
|
notifier := &generationNotifier{}
|
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
|
|
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
|
|
})
|
|
if err != nil || result == nil || result.Total != 2 || result.Succeeded != 2 || result.Failed != 0 || notifier.batchCalls != 1 || result.Notification == nil || result.Notification.Status != "succeeded" {
|
|
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
|
|
}
|
|
if len(notifier.batchRequest.Files) < 2 || len(notifier.batchRequest.IncludedReports) != 2 {
|
|
t.Fatalf("batch notification = %#v", notifier.batchRequest)
|
|
}
|
|
if result.Reports[0].OutputPath == result.Reports[1].OutputPath {
|
|
t.Fatalf("batch reports share output path %q", result.Reports[0].OutputPath)
|
|
}
|
|
for _, file := range notifier.batchRequest.Files {
|
|
if filepath.Dir(file.SourcePath) != outputDir || file.BundlePath == "" {
|
|
t.Fatalf("notification file = %#v", file)
|
|
}
|
|
if _, statErr := os.Stat(file.SourcePath); statErr != nil {
|
|
t.Fatalf("notification source %q: %v", file.SourcePath, statErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunBatchDetailedRejectsUnsupportedDistributorEndpointBeforeWork(t *testing.T) {
|
|
outputDir := t.TempDir()
|
|
cfg := generationDistributorConfig()
|
|
cfg.Notify.Distributor.Endpoint = "ftp://distributor.example.test"
|
|
bundle := generationBundle(t)
|
|
collector := &generationCollector{bundle: &bundle}
|
|
executor := &generationExecutor{}
|
|
notifier := &generationNotifier{}
|
|
|
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
|
Config: cfg, Batch: BatchMorning,
|
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
|
|
Collector: collector, Executor: executor, Notifier: notifier,
|
|
})
|
|
if err == nil || result != nil || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 || notifier.batchCalls != 0 {
|
|
t.Fatalf("RunBatchDetailed() result/error/collector/executor/notifier = %#v/%v/%t/%#v/%#v", result, err, collector.called, executor, notifier)
|
|
}
|
|
entries, readErr := os.ReadDir(outputDir)
|
|
if readErr != nil || len(entries) != 0 {
|
|
t.Fatalf("output directory entries/error = %v/%v", entries, readErr)
|
|
}
|
|
}
|
|
|
|
func TestRunBatchDetailedUsesDefaultAndConfiguredOutputDirectories(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: "working directory default",
|
|
directory: func(_ *testing.T, _ string) string {
|
|
return ""
|
|
},
|
|
wantDir: func(_ *testing.T, workingDir string, _ string) string {
|
|
return workingDir
|
|
},
|
|
},
|
|
{
|
|
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)
|
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
|
notifier := &generationNotifier{}
|
|
|
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
|
Config: cfg, Batch: BatchMorning,
|
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: workingDir,
|
|
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
|
|
})
|
|
wantDir := tt.wantDir(t, workingDir, configuredDir)
|
|
if err != nil || result == nil || result.Succeeded != len(result.Reports) || notifier.batchCalls != 1 {
|
|
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
|
|
}
|
|
for _, item := range result.Reports {
|
|
if filepath.Dir(item.OutputPath) != wantDir {
|
|
t.Fatalf("report output %q, want directory %q", item.OutputPath, wantDir)
|
|
}
|
|
}
|
|
for _, file := range notifier.batchRequest.Files {
|
|
if filepath.Dir(file.SourcePath) != wantDir {
|
|
t.Fatalf("notification source %q, want directory %q", file.SourcePath, wantDir)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunBatchDetailedExplicitOutputDirectoryIgnoresConfiguredDirectory(t *testing.T) {
|
|
configuredPath := filepath.Join(t.TempDir(), "not-a-directory")
|
|
if err := os.WriteFile(configuredPath, []byte("not a directory"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
explicitDir := t.TempDir()
|
|
cfg := generationDistributorConfig()
|
|
cfg.Output.Directory = configuredPath
|
|
bundle := generationBundle(t)
|
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
|
|
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
|
Config: cfg, Batch: BatchMorning,
|
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: explicitDir,
|
|
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: &generationNotifier{},
|
|
})
|
|
if err != nil || result == nil || result.Succeeded != len(result.Reports) {
|
|
t.Fatalf("RunBatchDetailed() result/error = %#v/%v", result, err)
|
|
}
|
|
for _, item := range result.Reports {
|
|
if filepath.Dir(item.OutputPath) != explicitDir {
|
|
t.Fatalf("report output %q, want directory %q", item.OutputPath, explicitDir)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunBatchDetailedPreflightsAllOutputPaths(t *testing.T) {
|
|
bundle := generationBundle(t)
|
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
|
outputDir := t.TempDir()
|
|
if err := os.Mkdir(filepath.Join(outputDir, "tomorrow.md"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
todayPath := filepath.Join(outputDir, "today.md")
|
|
const previousReport = "previous report"
|
|
if err := os.WriteFile(todayPath, []byte(previousReport), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
executor := &generationExecutor{}
|
|
promptInspectedBeforeCollection := false
|
|
collector := &generationCollector{
|
|
bundle: &bundle,
|
|
beforeRun: func() {
|
|
promptInspectedBeforeCollection = executor.promptInspections > 0
|
|
},
|
|
}
|
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
|
|
Collector: collector, Executor: executor, Notifier: &generationNotifier{},
|
|
})
|
|
if err == nil || result != nil || !collector.called || !promptInspectedBeforeCollection || executor.called {
|
|
t.Fatalf("RunBatchDetailed() result/error/collection/inspection/execution = %#v/%v/%t/%t/%t", result, err, collector.called, promptInspectedBeforeCollection, executor.called)
|
|
}
|
|
if data, readErr := os.ReadFile(todayPath); readErr != nil || string(data) != previousReport {
|
|
t.Fatalf("earlier output = %q, error = %v", data, readErr)
|
|
}
|
|
if info, statErr := os.Stat(filepath.Join(outputDir, "tomorrow.md")); statErr != nil || !info.IsDir() {
|
|
t.Fatalf("blocked output info/error = %#v/%v", info, statErr)
|
|
}
|
|
}
|
|
|
|
func TestRunBatchDetailedRetainsReportCountsWhenNotificationFails(t *testing.T) {
|
|
bundle := generationBundle(t)
|
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
|
outputDir := t.TempDir()
|
|
notifier := &generationNotifier{batchErr: errors.New("distributor unavailable")}
|
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
|
|
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
|
|
})
|
|
if err != nil || result == nil || result.Total != len(result.Reports) || result.Succeeded != len(result.Reports) || result.Failed != 0 || result.Notification == nil || result.Notification.Status != "failed" {
|
|
t.Fatalf("RunBatchDetailed() result/error = %#v/%v", result, err)
|
|
}
|
|
for _, item := range result.Reports {
|
|
if item.Status != "succeeded" || item.OutputPath == "" {
|
|
t.Fatalf("report result = %#v", item)
|
|
}
|
|
if _, statErr := os.Stat(item.OutputPath); statErr != nil {
|
|
t.Fatalf("published output %q: %v", item.OutputPath, statErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunBatchReturnsNotificationFailureWithoutReportFailureWording(t *testing.T) {
|
|
bundle := generationBundle(t)
|
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
|
err := RunBatch(context.Background(), BatchRequest{
|
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
|
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: &generationNotifier{batchErr: errors.New("distributor unavailable")},
|
|
})
|
|
var batchErr BatchError
|
|
if !errors.As(err, &batchErr) || batchErr.Result == nil || batchErr.Result.Failed != 0 || batchErr.Result.Notification == nil || batchErr.Result.Notification.Status != "failed" || !strings.Contains(err.Error(), "notification failed") || strings.Contains(err.Error(), "reports failed") {
|
|
t.Fatalf("RunBatch() error/result = %v/%#v", err, batchErr.Result)
|
|
}
|
|
}
|
|
|
|
func generationDistributorConfig() config.Config {
|
|
cfg := generationConfig()
|
|
cfg.Notify.Distributor.Enabled = true
|
|
cfg.Notify.Distributor.PipelineIDTemplate = "weather"
|
|
return cfg
|
|
}
|