Add batch notification app identity types

This commit is contained in:
2026-06-17 20:44:34 +00:00
parent f1d4e38414
commit a82f03feb8
3 changed files with 205 additions and 7 deletions

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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
}