diff --git a/internal/app/app.go b/internal/app/app.go index da8cd86..29aa7ff 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -117,13 +117,33 @@ type ReportResult struct { } type BatchResult struct { - Batch BatchKind `json:"batch"` - StartedAt time.Time `json:"startedAt"` - FinishedAt time.Time `json:"finishedAt"` - Total int `json:"total"` - Succeeded int `json:"succeeded"` - Failed int `json:"failed"` - Reports []BatchReportResult `json:"reports"` + Batch BatchKind `json:"batch"` + StartedAt time.Time `json:"startedAt"` + FinishedAt time.Time `json:"finishedAt"` + Total int `json:"total"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + Notification *BatchNotificationResult `json:"notification,omitempty"` + Reports []BatchReportResult `json:"reports"` +} + +type BatchNotificationResult struct { + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + RunID string `json:"runId,omitempty"` + PipelineID string `json:"pipelineId,omitempty"` + BundleID string `json:"bundleId,omitempty"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` + Path string `json:"path,omitempty"` + IncludedReports []BatchNotificationReport `json:"includedReports,omitempty"` + Error string `json:"error,omitempty"` +} + +type BatchNotificationReport struct { + ReportID report.ID `json:"reportId"` + RunID string `json:"runId"` + SourcePath string `json:"sourcePath"` + BundlePaths []string `json:"bundlePaths"` } type BatchReportResult struct { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 1a5557e..06d8596 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -2214,6 +2214,122 @@ func TestResolveGenerateStorm(t *testing.T) { } } +func TestBatchRunIDUsesUTCStartAndBatchName(t *testing.T) { + tests := []struct { + name string + startedAt time.Time + batch BatchKind + want string + }{ + { + name: "morning", + startedAt: mustParse("2026-05-29T05:00:00-05:00"), + batch: BatchMorning, + want: "20260529T100000.000000000Z_morning", + }, + { + name: "evening", + startedAt: mustParse("2026-05-29T18:30:45-05:00"), + batch: BatchEvening, + want: "20260529T233045.000000000Z_evening", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := batchRunID(tt.startedAt, tt.batch); got != tt.want { + t.Fatalf("batchRunID() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestRenderBatchNotificationIdentity(t *testing.T) { + cfg := config.Defaults() + cfg.Location.ID = "home" + cfg.WeatherAPI.Timezone = "America/Chicago" + cfg.Notify.Distributor.Batch.PipelineIDTemplate = "weatherreporter.{batch_started_date}" + cfg.Notify.Distributor.Batch.BundleIDTemplate = "weatherreporter.{location_id}.{batch}" + cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = "{bundle_id}.{batch_run_id}" + startedAt := mustParse("2026-05-30T03:30:00Z") + runID := batchRunID(startedAt, BatchEvening) + + identity, err := renderBatchNotificationIdentity(cfg, BatchEvening, runID, startedAt) + if err != nil { + t.Fatalf("renderBatchNotificationIdentity() error = %v", err) + } + + if identity.PipelineID != "weatherreporter.2026-05-29" { + t.Fatalf("PipelineID = %q, want local batch date", identity.PipelineID) + } + if identity.BundleID != "weatherreporter.home.evening" { + t.Fatalf("BundleID = %q, want rendered bundle id", identity.BundleID) + } + wantKey := "weatherreporter.home.evening.20260530T033000.000000000Z_evening" + if identity.IdempotencyKey != wantKey { + t.Fatalf("IdempotencyKey = %q, want %q", identity.IdempotencyKey, wantKey) + } +} + +func TestBatchResultJSONOmitsNilNotification(t *testing.T) { + data, err := json.Marshal(BatchResult{ + Batch: BatchMorning, + Reports: []BatchReportResult{}, + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + if strings.Contains(string(data), "notification") { + t.Fatalf("BatchResult JSON = %s, want no notification field", data) + } +} + +func TestBatchResultJSONIncludesNotification(t *testing.T) { + result := BatchResult{ + Batch: BatchEvening, + Notification: &BatchNotificationResult{ + Status: "accepted", + RunID: "distributor-run", + PipelineID: "weatherreporter", + BundleID: "weatherreporter.home.evening", + IdempotencyKey: "weatherreporter.home.evening.20260529T233000.000000000Z_evening", + Path: "notifications/batches/evening/2026-05-29/20260529T233000.000000000Z_evening.distributor.json", + IncludedReports: []BatchNotificationReport{ + { + ReportID: report.Tomorrow, + RunID: "20260529T233000.000000000Z_tomorrow", + SourcePath: "reports/tomorrow.md", + BundlePaths: []string{"tomorrow/index.md"}, + }, + }, + }, + Reports: []BatchReportResult{}, + } + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + for _, want := range []string{ + `"notification":{`, + `"status":"accepted"`, + `"runId":"distributor-run"`, + `"pipelineId":"weatherreporter"`, + `"bundleId":"weatherreporter.home.evening"`, + `"idempotencyKey":"weatherreporter.home.evening.20260529T233000.000000000Z_evening"`, + `"path":"notifications/batches/evening/2026-05-29/20260529T233000.000000000Z_evening.distributor.json"`, + `"includedReports":[`, + `"reportId":"tomorrow"`, + `"sourcePath":"reports/tomorrow.md"`, + `"bundlePaths":["tomorrow/index.md"]`, + } { + if !strings.Contains(string(data), want) { + t.Fatalf("BatchResult JSON = %s, want %s", data, want) + } + } +} + func TestRunBatchContinuesAfterReportFailure(t *testing.T) { server := dailyBundleServer(t) cfg := dailyWorkspaceConfig(t, server) diff --git a/internal/app/batch_notification.go b/internal/app/batch_notification.go new file mode 100644 index 0000000..59f9601 --- /dev/null +++ b/internal/app/batch_notification.go @@ -0,0 +1,62 @@ +package app + +import ( + "fmt" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/config" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +const runIDTimestampLayout = "20060102T150405.000000000Z" + +type batchNotificationIdentity struct { + PipelineID string + BundleID string + IdempotencyKey string +} + +func batchRunID(startedAt time.Time, batch BatchKind) string { + return startedAt.UTC().Format(runIDTimestampLayout) + "_" + string(batch) +} + +func renderBatchNotificationIdentity(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (batchNotificationIdentity, error) { + values, err := batchNotificationTemplateValues(cfg, batch, runID, startedAt) + if err != nil { + return batchNotificationIdentity{}, err + } + + bundleID, err := config.RenderDistributorBatchBundleID(cfg.Notify.Distributor.Batch.BundleIDTemplate, values) + if err != nil { + return batchNotificationIdentity{}, err + } + values.BundleID = bundleID + + pipelineID, err := config.RenderDistributorBatchPipelineID(cfg.Notify.Distributor.Batch.PipelineIDTemplate, values) + if err != nil { + return batchNotificationIdentity{}, err + } + idempotencyKey, err := config.RenderDistributorBatchIdempotencyKey(cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate, values) + if err != nil { + return batchNotificationIdentity{}, err + } + + return batchNotificationIdentity{ + PipelineID: pipelineID, + BundleID: bundleID, + IdempotencyKey: idempotencyKey, + }, nil +} + +func batchNotificationTemplateValues(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (config.DistributorBatchTemplateValues, error) { + location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone) + if err != nil { + return config.DistributorBatchTemplateValues{}, fmt.Errorf("load batch notification timezone: %w", err) + } + return config.DistributorBatchTemplateValues{ + LocationID: cfg.Location.ID, + Batch: string(batch), + BatchRunID: runID, + BatchStartedDate: startedAt.In(location).Format(timeutil.DateLayout), + }, nil +}