Add batch notification app identity types
This commit is contained in:
@@ -117,13 +117,33 @@ type ReportResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BatchResult struct {
|
type BatchResult struct {
|
||||||
Batch BatchKind `json:"batch"`
|
Batch BatchKind `json:"batch"`
|
||||||
StartedAt time.Time `json:"startedAt"`
|
StartedAt time.Time `json:"startedAt"`
|
||||||
FinishedAt time.Time `json:"finishedAt"`
|
FinishedAt time.Time `json:"finishedAt"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
Succeeded int `json:"succeeded"`
|
Succeeded int `json:"succeeded"`
|
||||||
Failed int `json:"failed"`
|
Failed int `json:"failed"`
|
||||||
Reports []BatchReportResult `json:"reports"`
|
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 {
|
type BatchReportResult struct {
|
||||||
|
|||||||
@@ -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) {
|
func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
|
||||||
server := dailyBundleServer(t)
|
server := dailyBundleServer(t)
|
||||||
cfg := dailyWorkspaceConfig(t, server)
|
cfg := dailyWorkspaceConfig(t, server)
|
||||||
|
|||||||
62
internal/app/batch_notification.go
Normal file
62
internal/app/batch_notification.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user