Implemented weather stories support
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
This commit is contained in:
@@ -160,6 +160,63 @@ func TestMapDiscussionKeyMessagesPreserveOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapWeatherStoryRunParentRowNullables(t *testing.T) {
|
||||
asOf := time.Date(2026, 5, 30, 9, 0, 34, 0, time.FixedZone("CDT", -5*3600))
|
||||
run := mapWeatherStoryRunParentRow(weatherStoryRunParentRow{
|
||||
EventID: "evt-story-run",
|
||||
OfficeID: sql.NullString{String: "LSX", Valid: true},
|
||||
AsOf: asOf,
|
||||
})
|
||||
|
||||
if run.OfficeID != "LSX" {
|
||||
t.Fatalf("expected office id LSX, got %q", run.OfficeID)
|
||||
}
|
||||
if run.AsOf.Location().String() != "UTC" {
|
||||
t.Fatalf("expected asOf to be UTC-normalized, got %v", run.AsOf)
|
||||
}
|
||||
if run.Stories != nil {
|
||||
t.Fatalf("expected nil stories before child load, got %+v", run.Stories)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapWeatherStoryRowMapsFields(t *testing.T) {
|
||||
start := time.Date(2026, 5, 30, 8, 46, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||
end := time.Date(2026, 5, 31, 11, 0, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||
updated := time.Date(2026, 5, 30, 9, 0, 34, 0, time.FixedZone("CDT", -5*3600))
|
||||
story := mapWeatherStoryRow(weatherStoryRow{
|
||||
StoryIndex: 2,
|
||||
OfficeID: sql.NullString{String: "LSX", Valid: true},
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
UpdatedAt: updated,
|
||||
Title: sql.NullString{String: "Several Chances for Rain Through Monday", Valid: true},
|
||||
Description: sql.NullString{String: "Scattered showers and thunderstorms.", Valid: true},
|
||||
AltText: sql.NullString{String: "Forecast slide.", Valid: true},
|
||||
Priority: true,
|
||||
StoryOrder: 1,
|
||||
DownloadURL: sql.NullString{String: "https://api.weather.gov/offices/LSX/weatherstories/download/story-1", Valid: true},
|
||||
})
|
||||
|
||||
if story.OfficeID != "LSX" {
|
||||
t.Fatalf("expected office id LSX, got %q", story.OfficeID)
|
||||
}
|
||||
if story.Title != "Several Chances for Rain Through Monday" {
|
||||
t.Fatalf("unexpected title: %q", story.Title)
|
||||
}
|
||||
if !story.Priority {
|
||||
t.Fatalf("expected priority true")
|
||||
}
|
||||
if story.Order != 1 {
|
||||
t.Fatalf("expected order 1, got %d", story.Order)
|
||||
}
|
||||
if story.DownloadURL == "" {
|
||||
t.Fatalf("expected download URL")
|
||||
}
|
||||
if story.StartTime.Location().String() != "UTC" || story.EndTime.Location().String() != "UTC" || story.UpdatedAt.Location().String() != "UTC" {
|
||||
t.Fatalf("expected story timestamps to be UTC-normalized, got %s %s %s", story.StartTime, story.EndTime, story.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachAlertReferencesPreservesOrder(t *testing.T) {
|
||||
sent1 := time.Date(2026, 3, 20, 1, 0, 0, 0, time.UTC)
|
||||
sent2 := sent1.Add(10 * time.Minute)
|
||||
|
||||
28
internal/adapters/outbound/postgres/weatherstories_mapper.go
Normal file
28
internal/adapters/outbound/postgres/weatherstories_mapper.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// weatherstories_mapper.go maps weather story rows into weather model payloads.
|
||||
// Layer: adapters/outbound/postgres weather stories feature.
|
||||
package postgres
|
||||
|
||||
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||
|
||||
func mapWeatherStoryRunParentRow(row weatherStoryRunParentRow) model.WeatherStoryRun {
|
||||
return model.WeatherStoryRun{
|
||||
OfficeID: stringValue(row.OfficeID),
|
||||
AsOf: row.AsOf.UTC(),
|
||||
Stories: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func mapWeatherStoryRow(row weatherStoryRow) model.WeatherStory {
|
||||
return model.WeatherStory{
|
||||
OfficeID: stringValue(row.OfficeID),
|
||||
StartTime: row.StartTime.UTC(),
|
||||
EndTime: row.EndTime.UTC(),
|
||||
UpdatedAt: row.UpdatedAt.UTC(),
|
||||
Title: stringValue(row.Title),
|
||||
Description: stringValue(row.Description),
|
||||
AltText: stringValue(row.AltText),
|
||||
Priority: row.Priority,
|
||||
Order: row.StoryOrder,
|
||||
DownloadURL: stringValue(row.DownloadURL),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// weatherstories_queries.go contains SQL text for weather story reads.
|
||||
// Layer: adapters/outbound/postgres weather stories feature.
|
||||
package postgres
|
||||
|
||||
const (
|
||||
queryLatestWeatherStoryRun = `
|
||||
SELECT
|
||||
event_id,
|
||||
office_id,
|
||||
as_of
|
||||
FROM weather_story_runs
|
||||
ORDER BY as_of DESC, event_emitted_at DESC
|
||||
LIMIT 1`
|
||||
|
||||
queryWeatherStoriesForRun = `
|
||||
SELECT
|
||||
story_index,
|
||||
office_id,
|
||||
start_time,
|
||||
end_time,
|
||||
updated_at,
|
||||
title,
|
||||
description,
|
||||
alt_text,
|
||||
priority,
|
||||
story_order,
|
||||
download_url
|
||||
FROM weather_stories
|
||||
WHERE run_event_id = $1
|
||||
ORDER BY story_index ASC`
|
||||
|
||||
queryLatestWeatherStory = `
|
||||
SELECT
|
||||
story_index,
|
||||
office_id,
|
||||
start_time,
|
||||
end_time,
|
||||
updated_at,
|
||||
title,
|
||||
description,
|
||||
alt_text,
|
||||
priority,
|
||||
story_order,
|
||||
download_url
|
||||
FROM weather_stories
|
||||
ORDER BY updated_at DESC, as_of DESC, story_order ASC, story_index ASC
|
||||
LIMIT 1`
|
||||
)
|
||||
103
internal/adapters/outbound/postgres/weatherstories_read.go
Normal file
103
internal/adapters/outbound/postgres/weatherstories_read.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// weatherstories_read.go executes weather story queries.
|
||||
// Layer: adapters/outbound/postgres weather stories feature.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||
)
|
||||
|
||||
func (r *Repository) LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, fmt.Errorf("postgres repository is not configured")
|
||||
}
|
||||
|
||||
var row weatherStoryRunParentRow
|
||||
err := r.db.QueryRowContext(ctx, queryLatestWeatherStoryRun).Scan(
|
||||
&row.EventID,
|
||||
&row.OfficeID,
|
||||
&row.AsOf,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query latest weather story run: %w", err)
|
||||
}
|
||||
|
||||
run := mapWeatherStoryRunParentRow(row)
|
||||
stories, err := r.loadWeatherStories(ctx, row.EventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
run.Stories = stories
|
||||
|
||||
return &run, nil
|
||||
}
|
||||
|
||||
func (r *Repository) LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, fmt.Errorf("postgres repository is not configured")
|
||||
}
|
||||
|
||||
var row weatherStoryRow
|
||||
err := r.db.QueryRowContext(ctx, queryLatestWeatherStory).Scan(
|
||||
&row.StoryIndex,
|
||||
&row.OfficeID,
|
||||
&row.StartTime,
|
||||
&row.EndTime,
|
||||
&row.UpdatedAt,
|
||||
&row.Title,
|
||||
&row.Description,
|
||||
&row.AltText,
|
||||
&row.Priority,
|
||||
&row.StoryOrder,
|
||||
&row.DownloadURL,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query latest weather story: %w", err)
|
||||
}
|
||||
|
||||
story := mapWeatherStoryRow(row)
|
||||
return &story, nil
|
||||
}
|
||||
|
||||
func (r *Repository) loadWeatherStories(ctx context.Context, eventID string) ([]model.WeatherStory, error) {
|
||||
rows, err := r.db.QueryContext(ctx, queryWeatherStoriesForRun, eventID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query weather stories: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]model.WeatherStory, 0)
|
||||
for rows.Next() {
|
||||
var row weatherStoryRow
|
||||
if err := rows.Scan(
|
||||
&row.StoryIndex,
|
||||
&row.OfficeID,
|
||||
&row.StartTime,
|
||||
&row.EndTime,
|
||||
&row.UpdatedAt,
|
||||
&row.Title,
|
||||
&row.Description,
|
||||
&row.AltText,
|
||||
&row.Priority,
|
||||
&row.StoryOrder,
|
||||
&row.DownloadURL,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan weather story row: %w", err)
|
||||
}
|
||||
out = append(out, mapWeatherStoryRow(row))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate weather story rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
28
internal/adapters/outbound/postgres/weatherstories_rows.go
Normal file
28
internal/adapters/outbound/postgres/weatherstories_rows.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// weatherstories_rows.go defines row DTOs for weather story reads.
|
||||
// Layer: adapters/outbound/postgres weather stories feature.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type weatherStoryRunParentRow struct {
|
||||
EventID string
|
||||
OfficeID sql.NullString
|
||||
AsOf time.Time
|
||||
}
|
||||
|
||||
type weatherStoryRow struct {
|
||||
StoryIndex int
|
||||
OfficeID sql.NullString
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
UpdatedAt time.Time
|
||||
Title sql.NullString
|
||||
Description sql.NullString
|
||||
AltText sql.NullString
|
||||
Priority bool
|
||||
StoryOrder int
|
||||
DownloadURL sql.NullString
|
||||
}
|
||||
Reference in New Issue
Block a user