package cli import ( "encoding/json" "errors" "strings" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/app" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/state" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) { generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC) acceptedAt := generatedAt.Add(time.Minute) startedAt := acceptedAt.Add(time.Minute) finishedAt := startedAt.Add(time.Minute) result := &app.ReportResult{ DataPackagePath: "/runs/hourly/data_package.yaml", PreparationPath: "/runs/hourly/preparation.json", ExecutionPath: "/runs/hourly/execution.json", LLMDebugPath: "/operator-debug/hourly/2026-05-29/run-123", ReportPath: "/runs/hourly/report.md", OutputPath: "/copies/hourly.md", MetadataPath: "/runs/hourly/metadata.json", GeneratedTextRawPath: "/runs/hourly/generated_text_raw.json", GeneratedTextPath: "/runs/hourly/generated_text.json", RenderContextPath: "/runs/hourly/render_context.json", Metadata: state.Metadata{ ReportID: report.Hourly, PromptID: "weather.hourly_generated_text", RunID: "20260529T133000Z_hourly", GeneratedAt: generatedAt, ValidPeriod: testSummaryPeriod(generatedAt), }, Notification: &app.NotificationResult{ Status: "succeeded", UploadStatus: "accepted", RunID: "distributor-run", PipelineID: "weatherreporter.hourly", BundleID: "weatherreporter.home.hourly", IdempotencyKey: "weatherreporter.home.hourly.20260529T133000Z_hourly", AcceptedAt: acceptedAt, StartedAt: &startedAt, FinishedAt: &finishedAt, Report: []byte(`{"actions":[{"action":"replace_older"}]}`), }, } summary := newGenerateSummary(result, nil) if summary.Command != "generate" || summary.Status != "succeeded" { t.Fatalf("summary command/status = %q/%q, want generate/succeeded", summary.Command, summary.Status) } if summary.ReportID != report.Hourly || summary.ReportName != "Hourly Report" || summary.PromptID != "weather.hourly_generated_text" || summary.RunID != "20260529T133000Z_hourly" { t.Fatalf("summary identity = %#v, want hourly report identity", summary) } if summary.PreparationPath == "" || summary.ExecutionPath == "" || summary.LLMDebugPath == "" || summary.GeneratedTextRawPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" { t.Fatalf("generated-text paths = %#v, want generated-text artifact paths", summary) } if summary.Notification == nil || summary.Notification.RunID != "distributor-run" || summary.Notification.AcceptedAt == nil || !summary.Notification.AcceptedAt.Equal(acceptedAt) { t.Fatalf("notification = %#v, want summarized distributor result", summary.Notification) } data, err := json.Marshal(summary) if err != nil { t.Fatalf("Marshal() error = %v", err) } if strings.Contains(string(data), "replace_older") || strings.Contains(string(data), "actions") { t.Fatalf("summary JSON includes raw distributor report payload:\n%s", string(data)) } if strings.Contains(string(data), "preflightPath") || strings.Contains(string(data), "generatedTextResultPath") || !strings.Contains(string(data), "preparationPath") || !strings.Contains(string(data), "executionPath") { t.Fatalf("summary JSON does not use prompt artifact path names:\n%s", string(data)) } } func TestNewGenerateSummaryOmitsNotificationWhenNotAttempted(t *testing.T) { generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC) result := &app.ReportResult{ DataPackagePath: "/runs/daily/data_package.yaml", PreparationPath: "/runs/daily/preparation.json", ReportPath: "/runs/daily/report.md", OutputPath: "/copies/daily.md", MetadataPath: "/runs/daily/metadata.json", Metadata: state.Metadata{ ReportID: report.Daily, PromptID: "weather.daily_generated_text", RunID: "20260529T133000Z_daily", GeneratedAt: generatedAt, ValidPeriod: testSummaryPeriod(generatedAt), }, } summary := newGenerateSummary(result, nil) if summary.ReportID != report.Daily || summary.ReportName != "Daily Report" || summary.Status != "succeeded" { t.Fatalf("summary = %#v, want successful daily summary", summary) } if summary.Notification != nil { t.Fatalf("notification summary = %#v, want omitted", summary.Notification) } data, err := json.Marshal(summary) if err != nil { t.Fatalf("Marshal() error = %v", err) } for _, omitted := range []string{"notification"} { if strings.Contains(string(data), omitted) { t.Fatalf("summary JSON contains %q, want omitted:\n%s", omitted, string(data)) } } } func TestNewGenerateSummaryOmitsUnreachedArtifactPaths(t *testing.T) { result := &app.ReportResult{ DataPackagePath: "/runs/daily/data_package.yaml", PreparationPath: "/runs/daily/preparation.json", Metadata: state.Metadata{ ReportID: report.Daily, RunID: "20260529T133000Z_daily", }, } data, err := json.Marshal(newGenerateSummary(result, errors.New("metadata write failed"))) if err != nil { t.Fatalf("Marshal() error = %v", err) } text := string(data) for _, omitted := range []string{"executionPath", "reportPath", "outputPath", "metadataPath", "generatedTextRawPath", "generatedTextPath", "renderContextPath", "notificationPath"} { if strings.Contains(text, omitted) { t.Fatalf("partial summary includes unreached field %q:\n%s", omitted, text) } } if !strings.Contains(text, "dataPackagePath") || !strings.Contains(text, "preparationPath") { t.Fatalf("partial summary omits reached paths:\n%s", text) } } func TestNewGenerateSummaryForNotificationFailure(t *testing.T) { generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC) result := &app.ReportResult{ DataPackagePath: "/runs/hourly/data_package.yaml", PreparationPath: "/runs/hourly/preparation.json", ReportPath: "/runs/hourly/report.md", OutputPath: "/copies/hourly.md", MetadataPath: "/runs/hourly/metadata.json", Metadata: state.Metadata{ ReportID: report.Hourly, PromptID: "weather.hourly_generated_text", RunID: "20260529T133000Z_hourly", GeneratedAt: generatedAt, ValidPeriod: testSummaryPeriod(generatedAt), }, } err := errors.New(`notify report "hourly" run "20260529T133000Z_hourly": upload rejected`) summary := newGenerateSummary(result, err) if summary.Status != "failed" || summary.Error != err.Error() { t.Fatalf("status/error = %q/%q, want failed notification error", summary.Status, summary.Error) } if summary.ReportPath == "" || summary.MetadataPath == "" { t.Fatalf("artifact paths = report %q metadata %q, want retained output provenance", summary.ReportPath, summary.MetadataPath) } data, marshalErr := json.Marshal(summary) if marshalErr != nil { t.Fatalf("Marshal() error = %v", marshalErr) } if strings.Contains(string(data), "notificationPath") { t.Fatalf("summary JSON includes a notification receipt path:\n%s", data) } } func TestNewBatchSummaryStatusDerivation(t *testing.T) { startedAt := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC) finishedAt := startedAt.Add(2 * time.Minute) tests := []struct { name string result *app.BatchResult wantStatus string wantError string }{ { name: "success", result: &app.BatchResult{ Batch: app.BatchMorning, StartedAt: startedAt, FinishedAt: finishedAt, Total: 1, Succeeded: 1, Reports: []app.BatchReportResult{{ReportID: report.Today, Status: "succeeded"}}, }, wantStatus: "succeeded", }, { name: "report failure", result: &app.BatchResult{ Batch: app.BatchMorning, Total: 2, Succeeded: 1, Failed: 1, Reports: []app.BatchReportResult{ {ReportID: report.Today, Status: "succeeded"}, {ReportID: report.Tomorrow, Status: "failed", Error: "render failed"}, }, }, wantStatus: "failed", wantError: "batch morning failed: 1 of 2 reports failed", }, { name: "skipped notification", result: &app.BatchResult{ Batch: app.BatchEvening, Total: 2, Succeeded: 1, Failed: 1, Reports: []app.BatchReportResult{{ReportID: report.Tomorrow, Status: "failed"}}, Notification: &app.BatchNotificationResult{ Status: "skipped", Reason: "one or more reports failed", }, }, wantStatus: "failed", wantError: "batch evening failed: 1 of 2 reports failed", }, { name: "failed notification", result: &app.BatchResult{ Batch: app.BatchEvening, Total: 1, Succeeded: 1, Reports: []app.BatchReportResult{{ReportID: report.Tomorrow, Status: "succeeded"}}, Notification: &app.BatchNotificationResult{ Status: "failed", Error: "notify batch evening: upload rejected", }, }, wantStatus: "failed", wantError: "batch evening notification failed: notify batch evening: upload rejected", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { summary := newBatchSummary(tt.result) if summary.Command != "run" || summary.Status != tt.wantStatus { t.Fatalf("command/status = %q/%q, want run/%s", summary.Command, summary.Status, tt.wantStatus) } if summary.Error != tt.wantError { t.Fatalf("error = %q, want %q", summary.Error, tt.wantError) } if len(summary.Reports) != len(tt.result.Reports) { t.Fatalf("reports = %#v, want copied report list", summary.Reports) } }) } } func testSummaryPeriod(start time.Time) timeutil.Period { return timeutil.Period{ Start: start, End: start.Add(6 * time.Hour), } }