package app import ( "context" "encoding/json" "errors" "os" "path/filepath" "strings" "testing" "gitea.maximumdirect.net/eric/weatherreporter/internal/comparison" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) func TestCompareDetailedPublishesOneCoherentBundle(t *testing.T) { cfg := comparisonConfig() bundle := generationBundle(t) workingDir := t.TempDir() executor := &generationExecutor{} inspectedBeforeCollection := false result, err := CompareDetailed(context.Background(), ComparisonRequest{ Config: cfg, Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"}, WorkingDir: workingDir, Date: generationTime("2026-05-29T12:00:00-05:00"), Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")}, Collector: &generationCollector{bundle: &bundle, beforeRun: func() { inspectedBeforeCollection = executor.promptInspections == 1 && executor.profileInspections == 2 }}, Executor: executor, }) if err != nil { t.Fatalf("CompareDetailed() error = %v", err) } if result == nil || result.Total != 2 || result.Succeeded != 2 || result.Failed != 0 || executor.promptInspections != 1 || executor.profileInspections != 2 || executor.executeCalls != 2 || !inspectedBeforeCollection || result.ManifestPath == "" || result.DataPackagePath == "" { t.Fatalf("result/executor = %#v/%#v", result, executor) } if result.OutputDirectory != filepath.Dir(result.ManifestPath) || !filepath.IsAbs(result.ManifestPath) || !filepath.IsAbs(result.DataPackagePath) { t.Fatalf("published paths = %#v", result) } for index, profile := range result.Results { if profile.Position != index+1 || profile.Status != comparison.StatusSucceeded || !filepath.IsAbs(profile.ReportPath) || profile.Error != nil { t.Fatalf("profile result = %#v", profile) } } data, readErr := os.ReadFile(result.ManifestPath) if readErr != nil { t.Fatal(readErr) } var manifest comparison.Manifest if err := json.Unmarshal(data, &manifest); err != nil { t.Fatal(err) } if manifest.ComparisonID != result.ComparisonID || manifest.Total != result.Total || manifest.Succeeded != result.Succeeded || manifest.DataPackage.SHA256 == "" || len(manifest.Results) != 2 { t.Fatalf("manifest = %#v", manifest) } if manifest.Results[0].ReportPath != filepath.Base(result.Results[0].ReportPath) || manifest.Results[1].ReportPath != filepath.Base(result.Results[1].ReportPath) { t.Fatalf("manifest report paths = %#v", manifest.Results) } } func TestCompareDetailedPublishesPartialBundleAndReturnsAggregateError(t *testing.T) { cfg := comparisonConfig() bundle := generationBundle(t) executor := &generationExecutor{executeErrors: map[string]error{"weather-deep": errors.New("provider detail must not escape")}} result, err := CompareDetailed(context.Background(), ComparisonRequest{ Config: cfg, Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep", "weather-fallback"}, WorkingDir: t.TempDir(), Date: generationTime("2026-05-29T12:00:00-05:00"), Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")}, Collector: &generationCollector{bundle: &bundle}, Executor: executor, }) if err == nil || err.Error() != "comparison completed with 1 failed profiles" || result == nil || result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 { t.Fatalf("CompareDetailed() result/error = %#v/%v", result, err) } failure := result.Results[1] if failure.Status != comparison.StatusFailed || failure.ReportPath != "" || failure.Error == nil || strings.Contains(failure.Error.Message, "provider detail") { t.Fatalf("failure = %#v", failure) } if _, statErr := os.Stat(result.ManifestPath); statErr != nil { t.Fatalf("partial manifest: %v", statErr) } if _, statErr := os.Stat(filepath.Join(result.OutputDirectory, filepath.Base(result.Results[0].ReportPath))); statErr != nil { t.Fatalf("successful partial report: %v", statErr) } } func TestCompareDetailedPreflightsBeforePromptOrCollection(t *testing.T) { invalidDestination := filepath.Join(t.TempDir(), "not-a-directory") if err := os.WriteFile(invalidDestination, []byte("x"), 0o600); err != nil { t.Fatal(err) } bundle := generationBundle(t) collector := &generationCollector{bundle: &bundle} executor := &generationExecutor{} result, err := CompareDetailed(context.Background(), ComparisonRequest{ Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"}, WorkingDir: t.TempDir(), OutputDir: invalidDestination, Date: generationTime("2026-05-29T12:00:00-05:00"), Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")}, Collector: collector, Executor: executor, }) if err == nil || result == nil || collector.called || executor.promptInspections != 0 || executor.executeCalls != 0 || result.ManifestPath != "" { t.Fatalf("result/error/collector/executor = %#v/%v/%#v/%#v", result, err, collector, executor) } } func TestCompareDetailedPublishesManifestWhenEveryProfileFails(t *testing.T) { bundle := generationBundle(t) executor := &generationExecutor{executeErrors: map[string]error{ "weather-light": errors.New("first provider failure"), "weather-deep": errors.New("second provider failure"), }} result, err := CompareDetailed(context.Background(), ComparisonRequest{ Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"}, WorkingDir: t.TempDir(), Date: generationTime("2026-05-29T12:00:00-05:00"), Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")}, Collector: &generationCollector{bundle: &bundle}, Executor: executor, }) if err == nil || err.Error() != "comparison completed with 2 failed profiles" || result == nil || result.Succeeded != 0 || result.Failed != 2 || result.ManifestPath == "" { t.Fatalf("CompareDetailed() result/error = %#v/%v", result, err) } for _, profile := range result.Results { if profile.ReportPath != "" || profile.Error == nil { t.Fatalf("failed profile = %#v", profile) } } } func TestCompareDetailedCancellationPreservesPublishedBundle(t *testing.T) { workingDir := t.TempDir() bundle := generationBundle(t) request := ComparisonRequest{ Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"}, WorkingDir: workingDir, Date: generationTime("2026-05-29T12:00:00-05:00"), Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")}, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, } previous, err := CompareDetailed(context.Background(), request) if err != nil { t.Fatalf("initial CompareDetailed() error = %v", err) } before, err := os.ReadFile(previous.ManifestPath) if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) request.Replace = true request.Executor = &generationExecutor{cancelBeforeReturn: cancel} result, err := CompareDetailed(ctx, request) if !errors.Is(err, context.Canceled) || result == nil || result.ManifestPath != "" || result.DataPackagePath != "" { t.Fatalf("canceled CompareDetailed() result/error = %#v/%v", result, err) } after, readErr := os.ReadFile(previous.ManifestPath) if readErr != nil || string(after) != string(before) { t.Fatalf("published manifest changed = %q, error = %v", after, readErr) } } func TestCompareDetailedLeavesExistingBundleWhenPublicationPreflightChanges(t *testing.T) { workingDir := t.TempDir() target := filepath.Join(workingDir, "comparison-output") bundle := generationBundle(t) executor := &generationExecutor{beforeExecute: func(promptexec.ExecuteRequest) { _ = os.WriteFile(target, []byte("changed"), 0o600) }} result, err := CompareDetailed(context.Background(), ComparisonRequest{ Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"}, WorkingDir: workingDir, OutputDir: target, Date: generationTime("2026-05-29T12:00:00-05:00"), Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")}, Collector: &generationCollector{bundle: &bundle}, Executor: executor, }) if err == nil || result == nil || result.ManifestPath != "" || result.DataPackagePath != "" || result.Results[0].ReportPath != "" { t.Fatalf("CompareDetailed() result/error = %#v/%v", result, err) } data, readErr := os.ReadFile(target) if readErr != nil || string(data) != "changed" { t.Fatalf("destination = %q, error = %v", data, readErr) } } func comparisonConfig() config.Config { cfg := config.Defaults() cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home" return cfg }