Implemented the weather forecast discussion product from weatherfeeder v0.8.3
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:
45
internal/adapters/outbound/postgres/discussion_mapper.go
Normal file
45
internal/adapters/outbound/postgres/discussion_mapper.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// discussion_mapper.go maps forecast discussion rows into weather model payloads.
|
||||
// Layer: adapters/outbound/postgres discussion feature.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||
)
|
||||
|
||||
func mapDiscussionParentRow(row discussionParentRow) model.WeatherForecastDiscussion {
|
||||
return model.WeatherForecastDiscussion{
|
||||
OfficeID: stringValue(row.OfficeID),
|
||||
OfficeName: stringValue(row.OfficeName),
|
||||
Product: model.ForecastDiscussionProduct(strings.TrimSpace(row.Product)),
|
||||
IssuedAt: row.IssuedAt.UTC(),
|
||||
UpdatedAt: timePtr(row.UpdatedAt),
|
||||
ShortTerm: discussionSectionPtr(row.ShortTermQualifier, row.ShortTermIssuedAt, row.ShortTermText),
|
||||
LongTerm: discussionSectionPtr(row.LongTermQualifier, row.LongTermIssuedAt, row.LongTermText),
|
||||
KeyMessages: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func mapDiscussionKeyMessageRow(row discussionKeyMessageRow) string {
|
||||
return stringValue(row.MessageText)
|
||||
}
|
||||
|
||||
func discussionSectionPtr(
|
||||
qualifier sql.NullString,
|
||||
issuedAt sql.NullTime,
|
||||
text sql.NullString,
|
||||
) *model.WeatherForecastDiscussionSection {
|
||||
q := stringValue(qualifier)
|
||||
t := stringValue(text)
|
||||
i := timePtr(issuedAt)
|
||||
if q == "" && t == "" && i == nil {
|
||||
return nil
|
||||
}
|
||||
return &model.WeatherForecastDiscussionSection{
|
||||
Qualifier: q,
|
||||
IssuedAt: i,
|
||||
Text: t,
|
||||
}
|
||||
}
|
||||
31
internal/adapters/outbound/postgres/discussion_queries.go
Normal file
31
internal/adapters/outbound/postgres/discussion_queries.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// discussion_queries.go contains SQL text for forecast discussion reads.
|
||||
// Layer: adapters/outbound/postgres discussion feature.
|
||||
package postgres
|
||||
|
||||
const (
|
||||
queryLatestForecastDiscussion = `
|
||||
SELECT
|
||||
event_id,
|
||||
office_id,
|
||||
office_name,
|
||||
issued_at,
|
||||
updated_at,
|
||||
product,
|
||||
short_term_qualifier,
|
||||
short_term_issued_at,
|
||||
short_term_text,
|
||||
long_term_qualifier,
|
||||
long_term_issued_at,
|
||||
long_term_text
|
||||
FROM forecast_discussions
|
||||
ORDER BY issued_at DESC, event_emitted_at DESC
|
||||
LIMIT 1`
|
||||
|
||||
queryForecastDiscussionKeyMessages = `
|
||||
SELECT
|
||||
message_index,
|
||||
message_text
|
||||
FROM forecast_discussion_key_messages
|
||||
WHERE run_event_id = $1
|
||||
ORDER BY message_index ASC`
|
||||
)
|
||||
74
internal/adapters/outbound/postgres/discussion_read.go
Normal file
74
internal/adapters/outbound/postgres/discussion_read.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// discussion_read.go executes forecast discussion queries.
|
||||
// Layer: adapters/outbound/postgres discussion feature.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||
)
|
||||
|
||||
func (r *Repository) LatestForecastDiscussion(ctx context.Context) (*model.WeatherForecastDiscussion, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, fmt.Errorf("postgres repository is not configured")
|
||||
}
|
||||
|
||||
var row discussionParentRow
|
||||
err := r.db.QueryRowContext(ctx, queryLatestForecastDiscussion).Scan(
|
||||
&row.EventID,
|
||||
&row.OfficeID,
|
||||
&row.OfficeName,
|
||||
&row.IssuedAt,
|
||||
&row.UpdatedAt,
|
||||
&row.Product,
|
||||
&row.ShortTermQualifier,
|
||||
&row.ShortTermIssuedAt,
|
||||
&row.ShortTermText,
|
||||
&row.LongTermQualifier,
|
||||
&row.LongTermIssuedAt,
|
||||
&row.LongTermText,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query latest forecast discussion: %w", err)
|
||||
}
|
||||
|
||||
run := mapDiscussionParentRow(row)
|
||||
|
||||
keyMessages, err := r.loadForecastDiscussionKeyMessages(ctx, row.EventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
run.KeyMessages = keyMessages
|
||||
|
||||
return &run, nil
|
||||
}
|
||||
|
||||
func (r *Repository) loadForecastDiscussionKeyMessages(ctx context.Context, eventID string) ([]string, error) {
|
||||
rows, err := r.db.QueryContext(ctx, queryForecastDiscussionKeyMessages, eventID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query forecast discussion key messages: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var row discussionKeyMessageRow
|
||||
if err := rows.Scan(
|
||||
&row.MessageIndex,
|
||||
&row.MessageText,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan forecast discussion key message row: %w", err)
|
||||
}
|
||||
out = append(out, mapDiscussionKeyMessageRow(row))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate forecast discussion key message rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
28
internal/adapters/outbound/postgres/discussion_rows.go
Normal file
28
internal/adapters/outbound/postgres/discussion_rows.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// discussion_rows.go defines row DTOs for forecast discussion reads.
|
||||
// Layer: adapters/outbound/postgres discussion feature.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type discussionParentRow struct {
|
||||
EventID string
|
||||
OfficeID sql.NullString
|
||||
OfficeName sql.NullString
|
||||
IssuedAt time.Time
|
||||
UpdatedAt sql.NullTime
|
||||
Product string
|
||||
ShortTermQualifier sql.NullString
|
||||
ShortTermIssuedAt sql.NullTime
|
||||
ShortTermText sql.NullString
|
||||
LongTermQualifier sql.NullString
|
||||
LongTermIssuedAt sql.NullTime
|
||||
LongTermText sql.NullString
|
||||
}
|
||||
|
||||
type discussionKeyMessageRow struct {
|
||||
MessageIndex int
|
||||
MessageText sql.NullString
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||
)
|
||||
|
||||
func TestMapObservationParentRowNullables(t *testing.T) {
|
||||
@@ -84,6 +86,65 @@ func TestMapForecastPeriodRowNullables(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapDiscussionParentRowNullables(t *testing.T) {
|
||||
issuedAt := time.Date(2026, 3, 28, 19, 24, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||
updatedAt := issuedAt.Add(time.Hour)
|
||||
shortIssuedAt := issuedAt.Add(-5 * time.Minute)
|
||||
|
||||
discussion := mapDiscussionParentRow(discussionParentRow{
|
||||
OfficeID: sql.NullString{String: "LSX", Valid: true},
|
||||
OfficeName: sql.NullString{String: "National Weather Service Saint Louis MO", Valid: true},
|
||||
IssuedAt: issuedAt,
|
||||
UpdatedAt: sql.NullTime{Time: updatedAt, Valid: true},
|
||||
Product: "afd",
|
||||
ShortTermQualifier: sql.NullString{String: "(Tonight)", Valid: true},
|
||||
ShortTermIssuedAt: sql.NullTime{Time: shortIssuedAt, Valid: true},
|
||||
ShortTermText: sql.NullString{String: "Short term text", Valid: true},
|
||||
})
|
||||
|
||||
if discussion.OfficeID != "LSX" {
|
||||
t.Fatalf("expected office id LSX, got %q", discussion.OfficeID)
|
||||
}
|
||||
if discussion.Product != model.ForecastDiscussionProductAFD {
|
||||
t.Fatalf("expected product afd, got %q", discussion.Product)
|
||||
}
|
||||
if discussion.UpdatedAt == nil || discussion.UpdatedAt.Location().String() != "UTC" {
|
||||
t.Fatalf("expected updatedAt to be UTC-normalized, got %v", discussion.UpdatedAt)
|
||||
}
|
||||
if discussion.ShortTerm == nil {
|
||||
t.Fatalf("expected shortTerm section to be populated")
|
||||
}
|
||||
if discussion.ShortTerm.Qualifier != "(Tonight)" || discussion.ShortTerm.Text != "Short term text" {
|
||||
t.Fatalf("unexpected shortTerm section: %+v", discussion.ShortTerm)
|
||||
}
|
||||
if discussion.LongTerm != nil {
|
||||
t.Fatalf("expected longTerm nil, got %+v", discussion.LongTerm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapDiscussionSectionNilWhenAllFieldsMissing(t *testing.T) {
|
||||
got := discussionSectionPtr(sql.NullString{}, sql.NullTime{}, sql.NullString{})
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil discussion section, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapDiscussionKeyMessagesPreserveOrder(t *testing.T) {
|
||||
rows := []discussionKeyMessageRow{
|
||||
{MessageIndex: 0, MessageText: sql.NullString{String: "first", Valid: true}},
|
||||
{MessageIndex: 1, MessageText: sql.NullString{String: "second", Valid: true}},
|
||||
}
|
||||
|
||||
got := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
got = append(got, mapDiscussionKeyMessageRow(row))
|
||||
}
|
||||
|
||||
if len(got) != 2 || got[0] != "first" || got[1] != "second" {
|
||||
t.Fatalf("unexpected key message order: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachAlertReferencesPreservesOrder(t *testing.T) {
|
||||
sent1 := time.Date(2026, 3, 20, 1, 0, 0, 0, time.UTC)
|
||||
sent2 := sent1.Add(10 * time.Minute)
|
||||
|
||||
Reference in New Issue
Block a user