package app import ( "context" "errors" "os" "path/filepath" "strings" "testing" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" ) 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.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 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 }