Upload batch distributor notifications
This commit is contained in:
@@ -2447,10 +2447,11 @@ func TestBuildBatchNotificationRequestRejectsMissingReportPath(t *testing.T) {
|
||||
|
||||
func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyWorkspaceConfig(t, server)
|
||||
cfg := dailyNotificationConfig(t, server)
|
||||
collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31")
|
||||
cfg.WeatherAPI.BaseURL = ""
|
||||
collector := &recordingCollector{result: &collection}
|
||||
notifier := &recordingNotifier{}
|
||||
renderer := &selectiveRenderer{
|
||||
failRenderPrompt: "weather.tomorrow_generated_text",
|
||||
runBody: "# Batch Report\n",
|
||||
@@ -2462,6 +2463,7 @@ func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Collector: collector,
|
||||
Renderer: renderer,
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||
@@ -2476,6 +2478,12 @@ func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
|
||||
if renderer.runCalls != 0 || renderer.structuredRunCalls != 2 {
|
||||
t.Fatalf("renderer calls run=%d structured=%d, want successful reports to continue", renderer.runCalls, renderer.structuredRunCalls)
|
||||
}
|
||||
if len(notifier.requests) != 0 || len(notifier.batchRequests) != 0 {
|
||||
t.Fatalf("notification requests report=%d batch=%d, want none after report failure", len(notifier.requests), len(notifier.batchRequests))
|
||||
}
|
||||
if result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "one or more reports failed" {
|
||||
t.Fatalf("batch notification = %#v, want skipped after report failure", result.Notification)
|
||||
}
|
||||
var failedTomorrow bool
|
||||
var succeededDaily bool
|
||||
for _, item := range result.Reports {
|
||||
@@ -2497,6 +2505,64 @@ func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchMorningSendsOneBatchNotification(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyNotificationConfig(t, server)
|
||||
collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31")
|
||||
cfg.WeatherAPI.BaseURL = ""
|
||||
collector := &recordingCollector{result: &collection}
|
||||
notifier := &recordingNotifier{batchResult: successfulBatchNotificationResult()}
|
||||
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg,
|
||||
Batch: BatchMorning,
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Collector: collector,
|
||||
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||
}
|
||||
if result.Total != 3 || result.Succeeded != 3 || result.Failed != 0 {
|
||||
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/3/0", result.Total, result.Succeeded, result.Failed)
|
||||
}
|
||||
if len(notifier.requests) != 0 {
|
||||
t.Fatalf("per-report notification requests = %#v, want none", notifier.requests)
|
||||
}
|
||||
if len(notifier.batchRequests) != 1 {
|
||||
t.Fatalf("batch notification requests = %d, want 1", len(notifier.batchRequests))
|
||||
}
|
||||
req := notifier.batchRequests[0]
|
||||
if req.Batch != BatchMorning || req.RunID != "20260529T100000.000000000Z_morning" {
|
||||
t.Fatalf("batch request identity = %s/%s, want morning run id", req.Batch, req.RunID)
|
||||
}
|
||||
if len(req.IncludedReports) != 3 || len(req.Files) != 3 {
|
||||
t.Fatalf("batch request reports/files = %d/%d, want 3/3", len(req.IncludedReports), len(req.Files))
|
||||
}
|
||||
for _, file := range req.Files {
|
||||
if file.SourcePath == "" || file.BundlePath == "" {
|
||||
t.Fatalf("batch file = %#v, want source and bundle path", file)
|
||||
}
|
||||
if !strings.Contains(file.BundlePath, file.RunID) {
|
||||
t.Fatalf("bundle path %q does not include report run id %q", file.BundlePath, file.RunID)
|
||||
}
|
||||
}
|
||||
if result.Notification == nil || result.Notification.Status != "succeeded" || result.Notification.RunID != "batch-distributor-run" || result.Notification.Path == "" {
|
||||
t.Fatalf("batch notification = %#v, want succeeded result with artifact path", result.Notification)
|
||||
}
|
||||
if len(result.Notification.IncludedReports) != 3 {
|
||||
t.Fatalf("batch notification included reports = %d, want 3", len(result.Notification.IncludedReports))
|
||||
}
|
||||
artifact := readBatchNotificationForTest(t, result.Notification.Path)
|
||||
if artifact.Status != "succeeded" || artifact.Upload == nil || artifact.Upload.RunID != "batch-distributor-run" || artifact.RunStatus == nil {
|
||||
t.Fatalf("batch notification artifact = %#v, want succeeded upload and run status", artifact)
|
||||
}
|
||||
if len(artifact.Reports) != 3 {
|
||||
t.Fatalf("artifact included reports = %d, want 3", len(artifact.Reports))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchSuppressesPerReportNotification(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyNotificationConfig(t, server)
|
||||
@@ -2528,6 +2594,9 @@ func TestRunBatchSuppressesPerReportNotification(t *testing.T) {
|
||||
if len(notifier.requests) != 0 {
|
||||
t.Fatalf("notification requests = %#v, want none for batch-generated reports", notifier.requests)
|
||||
}
|
||||
if len(notifier.batchRequests) != 1 {
|
||||
t.Fatalf("batch notification requests = %d, want 1", len(notifier.batchRequests))
|
||||
}
|
||||
for _, item := range result.Reports {
|
||||
if item.Status != "succeeded" {
|
||||
t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status)
|
||||
@@ -2540,11 +2609,152 @@ func TestRunBatchSuppressesPerReportNotification(t *testing.T) {
|
||||
t.Fatalf("report %s metadata NotificationPath = %q, want empty", item.ReportID, metadata.NotificationPath)
|
||||
}
|
||||
}
|
||||
notificationDir := filepath.Join(cfg.Workspace.Root, cfg.Workspace.NotificationsDir)
|
||||
if _, err := os.Stat(notificationDir); err == nil {
|
||||
t.Fatalf("notification directory %q exists, want no per-report notification artifacts", notificationDir)
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("stat notification directory %q: %v", notificationDir, err)
|
||||
for _, item := range result.Reports {
|
||||
reportNotificationDir := filepath.Join(cfg.Workspace.Root, cfg.Workspace.NotificationsDir, string(item.ReportID))
|
||||
if _, err := os.Stat(reportNotificationDir); err == nil {
|
||||
t.Fatalf("per-report notification directory %q exists, want none", reportNotificationDir)
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("stat per-report notification directory %q: %v", reportNotificationDir, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchNotificationFailureKeepsReportItemsSucceeded(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyNotificationConfig(t, server)
|
||||
collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31")
|
||||
cfg.WeatherAPI.BaseURL = ""
|
||||
notifier := &recordingNotifier{batchErr: errors.New("batch upload rejected")}
|
||||
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg,
|
||||
Batch: BatchEvening,
|
||||
Now: mustParse("2026-05-29T18:00:00-05:00"),
|
||||
Collector: &recordingCollector{result: &collection},
|
||||
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||
}
|
||||
if result.Failed != 1 || result.Succeeded != 2 {
|
||||
t.Fatalf("summary succeeded/failed = %d/%d, want report successes plus notification failure", result.Succeeded, result.Failed)
|
||||
}
|
||||
for _, item := range result.Reports {
|
||||
if item.Status != "succeeded" {
|
||||
t.Fatalf("report %s status = %s, want succeeded despite batch notification failure", item.ReportID, item.Status)
|
||||
}
|
||||
}
|
||||
if result.Notification == nil || result.Notification.Status != "failed" || !strings.Contains(result.Notification.Error, "batch upload rejected") || result.Notification.Path == "" {
|
||||
t.Fatalf("batch notification = %#v, want failed upload result", result.Notification)
|
||||
}
|
||||
artifact := readBatchNotificationForTest(t, result.Notification.Path)
|
||||
if artifact.Status != "failed" || !strings.Contains(artifact.Error, "batch upload rejected") {
|
||||
t.Fatalf("batch notification artifact = %#v, want failed upload error", artifact)
|
||||
}
|
||||
|
||||
err = RunBatch(context.Background(), BatchRequest{
|
||||
Config: cfg,
|
||||
Batch: BatchEvening,
|
||||
Now: mustParse("2026-05-29T18:00:00-05:00"),
|
||||
Collector: &recordingCollector{result: &collection},
|
||||
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
|
||||
Notifier: &recordingNotifier{batchErr: errors.New("batch upload rejected")},
|
||||
})
|
||||
var batchErr BatchError
|
||||
if !errors.As(err, &batchErr) {
|
||||
t.Fatalf("RunBatch() error = %T %v, want BatchError", err, err)
|
||||
}
|
||||
if batchErr.Result == nil || !batchNotificationFailed(batchErr.Result) || batchReportFailures(batchErr.Result) != 0 {
|
||||
t.Fatalf("RunBatch() result = %#v, want notification-only batch failure", batchErr.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchNotificationStatusErrorPersistsStatusReport(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyNotificationConfig(t, server)
|
||||
collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31")
|
||||
cfg.WeatherAPI.BaseURL = ""
|
||||
notifier := &recordingNotifier{
|
||||
batchResult: &NotificationResult{
|
||||
RunID: "batch-distributor-run",
|
||||
Status: "accepted",
|
||||
UploadStatus: "accepted",
|
||||
StatusError: "status lookup unavailable",
|
||||
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
|
||||
},
|
||||
}
|
||||
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg,
|
||||
Batch: BatchMorning,
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Collector: &recordingCollector{result: &collection},
|
||||
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||
}
|
||||
if result.Failed != 0 || result.Notification == nil || result.Notification.Path == "" {
|
||||
t.Fatalf("result = %#v, want status-error notification artifact without batch failure", result)
|
||||
}
|
||||
artifact := readBatchNotificationForTest(t, result.Notification.Path)
|
||||
if artifact.StatusError != "status lookup unavailable" || artifact.RunStatus == nil || !strings.Contains(string(artifact.RunStatus.Report), "replace_older") {
|
||||
t.Fatalf("batch notification artifact = %#v, want status error and raw status report", artifact)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchDisabledDistributorSkipsBatchNotification(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyWorkspaceConfig(t, server)
|
||||
collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31")
|
||||
cfg.WeatherAPI.BaseURL = ""
|
||||
notifier := &recordingNotifier{}
|
||||
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg,
|
||||
Batch: BatchEvening,
|
||||
Now: mustParse("2026-05-29T18:00:00-05:00"),
|
||||
Collector: &recordingCollector{result: &collection},
|
||||
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||
}
|
||||
if result.Notification != nil || result.Failed != 0 {
|
||||
t.Fatalf("result notification/failed = %#v/%d, want disabled notification omitted", result.Notification, result.Failed)
|
||||
}
|
||||
if len(notifier.requests) != 0 || len(notifier.batchRequests) != 0 {
|
||||
t.Fatalf("notification requests report=%d batch=%d, want none when distributor disabled", len(notifier.requests), len(notifier.batchRequests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchDisabledBatchNotificationSkipsNotifier(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyNotificationConfig(t, server)
|
||||
cfg.Notify.Distributor.Batch.Enabled = false
|
||||
collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31")
|
||||
cfg.WeatherAPI.BaseURL = ""
|
||||
notifier := &recordingNotifier{}
|
||||
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg,
|
||||
Batch: BatchEvening,
|
||||
Now: mustParse("2026-05-29T18:00:00-05:00"),
|
||||
Collector: &recordingCollector{result: &collection},
|
||||
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||
}
|
||||
if result.Notification != nil || result.Failed != 0 {
|
||||
t.Fatalf("result notification/failed = %#v/%d, want disabled batch notification omitted", result.Notification, result.Failed)
|
||||
}
|
||||
if len(notifier.requests) != 0 || len(notifier.batchRequests) != 0 {
|
||||
t.Fatalf("notification requests report=%d batch=%d, want none when batch notification disabled", len(notifier.requests), len(notifier.batchRequests))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2629,6 +2839,9 @@ func TestRunBatchDynamicDailyReportsHaveDistinctIdentity(t *testing.T) {
|
||||
if len(notifier.requests) != 0 {
|
||||
t.Fatalf("notification requests = %#v, want none for batch-generated reports", notifier.requests)
|
||||
}
|
||||
if len(notifier.batchRequests) != 1 {
|
||||
t.Fatalf("batch notification requests = %d, want 1", len(notifier.batchRequests))
|
||||
}
|
||||
|
||||
dailyByDate := map[string]BatchReportResult{}
|
||||
runIDs := map[string]struct{}{}
|
||||
@@ -2675,6 +2888,18 @@ func TestRunBatchDynamicDailyReportsHaveDistinctIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if len(notifier.batchRequests[0].IncludedReports) != len(result.Reports) {
|
||||
t.Fatalf("batch included reports = %d, want %d", len(notifier.batchRequests[0].IncludedReports), len(result.Reports))
|
||||
}
|
||||
for _, included := range notifier.batchRequests[0].IncludedReports {
|
||||
if strings.Contains(included.SourcePath, outputDir) {
|
||||
t.Fatalf("batch notification source path = %q, want managed report path outside output dir", included.SourcePath)
|
||||
}
|
||||
if _, ok := reportPaths[included.SourcePath]; !ok {
|
||||
t.Fatalf("batch notification source path = %q, want one of %#v", included.SourcePath, reportPaths)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestRunBatchMorningUsesTodayOutputName(t *testing.T) {
|
||||
@@ -2842,6 +3067,15 @@ func successfulNotificationResult() *NotificationResult {
|
||||
}
|
||||
}
|
||||
|
||||
func successfulBatchNotificationResult() *NotificationResult {
|
||||
return &NotificationResult{
|
||||
RunID: "batch-distributor-run",
|
||||
Status: "succeeded",
|
||||
UploadStatus: "accepted",
|
||||
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
|
||||
}
|
||||
}
|
||||
|
||||
func successfulGeneratedTextRenderer(body string) *recordingRenderer {
|
||||
return &recordingRenderer{
|
||||
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
||||
@@ -3125,6 +3359,19 @@ func readMetadataForTest(t *testing.T, path string) state.Metadata {
|
||||
return metadata
|
||||
}
|
||||
|
||||
func readBatchNotificationForTest(t *testing.T, path string) state.BatchDistributorNotificationArtifact {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read batch notification %q: %v", path, err)
|
||||
}
|
||||
var artifact state.BatchDistributorNotificationArtifact
|
||||
if err := json.Unmarshal(data, &artifact); err != nil {
|
||||
t.Fatalf("decode batch notification %q: %v", path, err)
|
||||
}
|
||||
return artifact
|
||||
}
|
||||
|
||||
func assertGeneratedReportError(t *testing.T, err error, resolved report.Resolved, operation string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
@@ -3337,10 +3584,13 @@ type selectiveRenderer struct {
|
||||
}
|
||||
|
||||
type recordingNotifier struct {
|
||||
requests []NotificationRequest
|
||||
result *NotificationResult
|
||||
err error
|
||||
errByReport map[report.ID]error
|
||||
requests []NotificationRequest
|
||||
batchRequests []batchNotificationRequest
|
||||
result *NotificationResult
|
||||
batchResult *NotificationResult
|
||||
err error
|
||||
batchErr error
|
||||
errByReport map[report.ID]error
|
||||
}
|
||||
|
||||
func (n *recordingNotifier) Notify(_ context.Context, req NotificationRequest) (*NotificationResult, error) {
|
||||
@@ -3373,6 +3623,34 @@ func (n *recordingNotifier) Notify(_ context.Context, req NotificationRequest) (
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (n *recordingNotifier) NotifyBatch(_ context.Context, req batchNotificationRequest) (*NotificationResult, error) {
|
||||
n.batchRequests = append(n.batchRequests, req)
|
||||
if n.batchErr != nil {
|
||||
return nil, n.batchErr
|
||||
}
|
||||
if n.batchResult != nil {
|
||||
result := *n.batchResult
|
||||
if result.BundleID == "" {
|
||||
result.BundleID = req.BundleID
|
||||
}
|
||||
if result.IdempotencyKey == "" {
|
||||
result.IdempotencyKey = req.IdempotencyKey
|
||||
}
|
||||
if result.PipelineID == "" {
|
||||
result.PipelineID = req.PipelineID
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
return &NotificationResult{
|
||||
PipelineID: req.PipelineID,
|
||||
BundleID: req.BundleID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
RunID: "batch-distributor-run",
|
||||
Status: "accepted",
|
||||
UploadStatus: "accepted",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *selectiveRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) {
|
||||
r.renderCalls++
|
||||
if req.PromptID == r.failRenderPrompt {
|
||||
|
||||
Reference in New Issue
Block a user