24 Commits
v0.4.0 ... main

Author SHA1 Message Date
3136131519 Clean up completed roadmap documents
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-07-02 07:48:56 -05:00
8578db99b3 Document current conditions consensus selection
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-06-29 12:03:57 +00:00
86fd848a6f Preserve current conditions condition code presentation 2026-06-29 12:02:37 +00:00
6f8e01729d Split current conditions condition code querying 2026-06-29 12:00:32 +00:00
3c7ba89e15 Add current conditions condition code consensus helper 2026-06-29 11:56:20 +00:00
cfe6748330 Add a feature roadmap and implementation plan for a consensus-based algorithm for the condition code in the current conditions endpoint
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-06-28 16:44:34 -05:00
5a1134b955 Suppress superseded alerts in the /alerts/active endpoint
All checks were successful
ci/woodpecker/manual/build-image Pipeline was successful
2026-06-17 06:48:14 -05:00
2a33fe01cf Update to support upstream weatherfeeder v0.12.1 and add ends field to the alerts schema
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-06-16 20:08:03 -05:00
f4dd701204 Cleanup outlook endpoints and completed roadmap documentation
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-06-12 09:40:49 -05:00
6316783c3a Record active alert verification
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-06-12 14:11:42 +00:00
cdfe8881d9 Document active alert filtering behavior 2026-06-12 14:10:16 +00:00
8d0bc90f9d Cover active alert text rendering 2026-06-12 14:08:19 +00:00
12a7447494 Wire active alert filtering into HTTP 2026-06-12 14:05:33 +00:00
dd2f24316c Filter active alerts in app service 2026-06-12 14:03:00 +00:00
90abc536dd Add implementation plan to clean up expired alerts
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-06-12 08:57:30 -05:00
370d7c0928 Update the weatherfeeder dependency to v0.12.0
All checks were successful
ci/woodpecker/manual/build-image Pipeline was successful
ci/woodpecker/push/build-image Pipeline was successful
2026-06-12 13:34:33 +00:00
b556052966 Record outlook v2 verification
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-06-12 13:09:38 +00:00
c7760742e6 Document outlook v2 behavior 2026-06-12 13:07:17 +00:00
beeaeeaecb Cover outlook discussions in endpoint responses 2026-06-12 13:02:12 +00:00
1f2459923f Present outlook discussions in responses 2026-06-12 13:00:11 +00:00
fa1b2cb390 Filter outlook discussions with retained outlooks 2026-06-12 12:58:28 +00:00
82dd304f10 Read outlook discussions from Postgres 2026-06-12 12:56:20 +00:00
14c6954296 Update outlook dependency for v2 compile boundary 2026-06-12 12:52:05 +00:00
0135eb1153 Add roadmap and implementation plan to support the new outlook schema
Some checks failed
ci/woodpecker/push/build-image Pipeline failed
2026-06-12 07:47:01 -05:00
45 changed files with 2200 additions and 299 deletions

View File

@@ -26,14 +26,11 @@ database credentials through your normal secret-management process.
- Hourly and narrative forecasts
- Forecast discussions
- Weather stories
- Convective outlooks:
- `GET /outlooks/convective`
- `GET /outlooks/convective/active`
- `GET /outlooks/convective/location`
- Convective outlooks
Common query parameters include `format`, `units`, and route-specific options
such as forecast `precision`, timezone `tz` / `TZ`, and outlook filters
`day`, `outlookType`, and `containsLocation`.
`day` and `outlookType`.
See [`docs/api.md`](docs/api.md) for the HTTP contract.

View File

@@ -162,8 +162,10 @@ GET /observations?units=us&precision=1
GET /conditions/current
```
Returns current conditions aggregated from recent `observations` rows. The
implemented observation window is 30 minutes.
Returns current conditions from recent `observations` rows. Numeric fields are
aggregated over the implemented 30-minute observation window. `conditionCode`
is selected from the latest observation per source in that window by
source-balanced WMO family consensus.
Query parameters: `format`, `units`, `precision`.
@@ -171,6 +173,7 @@ Common `data` fields:
| Field | Type | Notes |
| --- | --- | --- |
| `conditionCode` | integer | WMO weather code selected by source-balanced family consensus |
| `conditionText` | string | optional text derived from WMO code and day/night flag |
| `isDay` | boolean | optional |
| `relativeHumidityPercent` | number | optional |
@@ -194,10 +197,15 @@ GET /conditions/current?format=json&precision=0
GET /alerts/active
```
Returns the latest active-alert snapshot.
Returns the latest stored alert run filtered to alerts active at request time,
omitting older alerts superseded by newer alert references in the same run.
Query parameters: `format`, `units`.
When no latest alert run exists, `data` is null. When a latest run exists but
no alerts are currently active, `data` remains an object and `alerts` is an
empty array.
Run `data` fields:
| Field | Type | Notes |
@@ -207,11 +215,21 @@ Run `data` fields:
| `latitude`, `longitude` | number | optional |
| `alerts` | array | active alerts, possibly empty |
Alerts are active when `messageType` is not `Cancel`, `effective` is absent or
at or before request time, and the alert end boundary is absent or after request
time. The end boundary prefers `ends`; if `ends` is absent, `expires` is used as
a fallback for older rows or providers that do not supply an alert-period end.
`onset` is presented when available but is not used as the active boundary.
After active-time filtering, alerts referenced by another alert in the same run
are omitted as superseded. References from update and cancel messages are both
honored, even when the referencing alert is not itself returned.
Alert fields include `id`, `event`, `headline`, `severity`, `urgency`,
`certainty`, `status`, `messageType`, `category`, `response`, `description`,
`instruction`, `sent`, `effective`, `onset`, `expires`, `areaDescription`,
`senderName`, and `references`. Most alert fields are optional except `id` when
an alert item is present.
`instruction`, `sent`, `effective`, `onset`, `ends`, `expires`,
`areaDescription`, `senderName`, and `references`. Most alert fields are
optional except `id` when an alert item is present. `ends` is the alert-period
end; `expires` is provider expiration metadata.
Reference fields are `id`, `identifier`, `sender`, and `sent`.
@@ -226,23 +244,22 @@ GET /alerts/active?format=text
```http
GET /outlooks/convective
GET /outlooks/convective/active
GET /outlooks/convective/location
```
Returns the latest SPC convective outlook run reconstructed from
weatherfeeder-owned Postgres tables.
weatherfeeder-owned `weather.outlook.v2` Postgres tables.
Route behavior:
- `/outlooks/convective` returns the latest run with all stored outlook
polygons unless user filters are supplied.
- `/outlooks/convective` returns the latest run with stored location-filtered
outlook polygons unless user filters are supplied.
- `/outlooks/convective/active` adds an active-time filter using the server's
current UTC time. Outlooks are active when `validFrom <= now < validTo`.
- `/outlooks/convective/location` adds the same active-time filter and
`containsLocation=true`.
When no latest run exists, `data` is null. When a run exists but filters match
no outlooks, `data` remains an object and `outlooks` is an empty array.
no outlooks, `data` remains an object and `outlooks` and `discussions` are
empty arrays. Outlook endpoints use latest-run semantics and do not accumulate
historical active outlooks across older runs.
Query parameters:
@@ -251,11 +268,10 @@ Query parameters:
| `format`, `units`, `tz` / `TZ` | all outlook routes | shared rules above |
| `day` | all outlook routes | `1`, `2`, or `3` |
| `outlookType` | all outlook routes | `categorical`, `tornado`, `hail`, or `wind` |
| `containsLocation` | `/outlooks/convective`, `/outlooks/convective/active` | boolean |
`outlookType` values are normalized case-insensitively. `containsLocation` is
rejected on `/outlooks/convective/location` because that route always applies
`containsLocation=true`. `precision` and unknown parameters are rejected.
`outlookType` values are normalized case-insensitively. Weatherfeeder v2
outlooks are already filtered for the configured location. `precision`,
`containsLocation`, and unknown parameters are rejected.
Run `data` fields:
@@ -266,6 +282,7 @@ Run `data` fields:
| `asOf` | RFC3339 datetime | required when `data` is not null |
| `issuedAt` | RFC3339 datetime | optional |
| `outlooks` | array | ordered outlook polygons, possibly empty |
| `discussions` | array | ordered day-level discussions, possibly empty |
Outlook fields:
@@ -273,23 +290,33 @@ Outlook fields:
| --- | --- | --- |
| `id`, `provider`, `product`, `outlookType`, `label` | string | required when an outlook is present |
| `day` | integer | SPC outlook day |
| `labelText`, `forecaster`, `headline`, `summary`, `discussion` | string | optional |
| `labelText`, `forecaster` | string | optional |
| `severityRank` | integer | optional |
| `validFrom`, `validTo`, `issuedAt`, `expiresAt` | RFC3339 datetime | required when an outlook is present |
| `sourceUrl`, `imageUrl` | string | optional |
| `containsLocation` | boolean | whether the outlook polygon contains the configured location |
| `geometry` | GeoJSON | stored outlook geometry |
Discussion fields:
| Field | Type | Notes |
| --- | --- | --- |
| `day` | integer | SPC outlook day |
| `headline`, `summary`, `discussion` | string | optional |
| `updatedAt` | RFC3339 datetime | optional |
GeoJSON coordinates use standard GeoJSON coordinate order: longitude, then
latitude. Timezone conversion applies to run `asOf`, run `issuedAt`, and each
outlook's `validFrom`, `validTo`, `issuedAt`, and `expiresAt`. Active filtering
compares instants and is not changed by the presentation timezone.
outlook's `validFrom`, `validTo`, `issuedAt`, and `expiresAt`, and discussion
`updatedAt`. Active filtering compares instants and is not changed by the
presentation timezone. Endpoint filters also filter `discussions` to days
represented by retained outlooks.
Examples:
```http
GET /outlooks/convective?day=1&outlookType=categorical
GET /outlooks/convective/location?format=text&tz=CDT
GET /outlooks/convective/active?format=text&tz=CDT
```
Example JSON response:
@@ -328,43 +355,22 @@ Example JSON response:
]
}
}
]
}
}
```
Example location-filtered JSON response:
```json
{
"data": {
"locationId": "stl",
"asOf": "2026-06-11T18:00:00Z",
"outlooks": [
],
"discussions": [
{
"id": "spc-day1-tor-2pct",
"provider": "spc",
"product": "convective",
"day": 1,
"outlookType": "tornado",
"label": "2%",
"validFrom": "2026-06-11T18:00:00Z",
"validTo": "2026-06-12T12:00:00Z",
"issuedAt": "2026-06-11T17:00:00Z",
"expiresAt": "2026-06-12T12:00:00Z",
"containsLocation": true,
"geometry": {
"type": "Point",
"coordinates": [-90.2, 38.6]
}
"headline": "Severe storms possible",
"summary": "Scattered severe storms are possible.",
"discussion": "SPC discussion text.",
"updatedAt": "2026-06-11T17:30:00Z"
}
]
}
}
```
Text format uses the shared convective outlook template for all three outlook
routes and renders a no-data message when `data` is null.
Text format uses the shared convective outlook template for both outlook routes
and renders a no-data message when `data` is null.
### Forecasts

View File

@@ -8,10 +8,12 @@
`go.mod` depends on:
- `gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0`
- `gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0`
The repository code also depends on weatherfeeder canonical model types. Table
compatibility must match the SQL in `internal/adapters/outbound/postgres`.
For convective outlooks, `weatherapi` assumes weatherfeeder's
`weather.outlook.v2` table reset has already been applied.
## Boundary
@@ -37,7 +39,7 @@ Postgres owns persistence, backup, restore, and availability.
| Forecast discussion | `forecast_discussions`, `forecast_discussion_key_messages` |
| Weather story run | `weather_story_runs`, `weather_stories` |
| Latest weather story | `weather_stories` |
| Convective outlook run | `outlook_runs`, `outlooks` |
| Convective outlook run | `outlook_runs`, `outlooks`, `outlook_discussions` |
## Latest Row Selection
@@ -55,8 +57,10 @@ Latest parent resources use these ordering rules:
story_order ASC, story_index ASC`.
- convective outlook runs: `as_of DESC, event_emitted_at DESC`.
Current conditions aggregate `observations` rows where `observed_at` is inside
the application-provided observation window.
Current conditions aggregate numeric values from `observations` rows where
`observed_at` is inside the application-provided observation window. They also
use the latest row per `event_source` in that window to select `condition_code`
by source-balanced WMO family consensus.
## Child Ordering
@@ -68,7 +72,8 @@ Child rows are loaded separately and attached in stored order:
- forecast periods: `period_index ASC`;
- forecast discussion key messages: `message_index ASC`;
- weather stories for a run: `story_index ASC`;
- outlooks for a run: `outlook_index ASC`.
- outlooks for a run: `outlook_index ASC`;
- outlook discussions for a run: `discussion_index ASC`.
## Columns Read
@@ -76,15 +81,16 @@ The repository reads only these columns.
### `observations`
`event_id`, `station_id`, `station_name`, `observed_at`, `condition_code`,
`is_day`, `text_description`, `temperature_c`, `dewpoint_c`,
`event_id`, `event_source`, `station_id`, `station_name`, `observed_at`,
`condition_code`, `is_day`, `text_description`, `temperature_c`, `dewpoint_c`,
`wind_direction_degrees`, `wind_speed_kmh`, `wind_gust_kmh`,
`barometric_pressure_pa`, `visibility_meters`, `relative_humidity_percent`,
`apparent_temperature_c`, and `event_emitted_at`.
Current conditions additionally aggregate recent `observations` values for
Current conditions additionally read recent `observations` values for
temperature, apparent temperature, dewpoint, humidity, wind speed, wind
direction, condition code, and latest `is_day`.
direction, latest `is_day`, and latest condition-code candidates per
`event_source`.
### `observation_present_weather`
@@ -102,8 +108,8 @@ and `event_emitted_at`.
`alert_index`, `alert_id`, `event`, `headline`, `severity`, `urgency`,
`certainty`, `status`, `message_type`, `category`, `response`, `description`,
`instruction`, `sent`, `effective`, `onset`, `expires`, `area_description`,
`sender_name`, and `run_event_id`.
`instruction`, `sent`, `effective`, `onset`, `ends`, `expires`,
`area_description`, `sender_name`, and `run_event_id`.
### `alert_references`
@@ -160,12 +166,17 @@ routes.
`outlook_index`, `outlook_id`, `provider`, `product`, `day`, `outlook_type`,
`label`, `label_text`, `severity_rank`, `valid_from`, `valid_to`, `issued_at`,
`expires_at`, `forecaster`, `headline`, `summary`, `discussion`, `source_url`,
`image_url`, `contains_location`, `geometry_json`, and `run_event_id`.
`expires_at`, `forecaster`, `source_url`, `image_url`, `contains_location`,
`geometry_json`, and `run_event_id`.
`geometry_json` is copied into response GeoJSON without parsing or
reserializing. It must contain valid JSON.
### `outlook_discussions`
`discussion_index`, `day`, `headline`, `summary`, `discussion`, `updated_at`,
and `run_event_id`.
## Nullability and Time Assumptions
The repository scans nullable columns with `sql.Null*` types and maps them to

View File

@@ -8,8 +8,8 @@ This document describes the internal HTTP adapter under
The HTTP adapter turns feedapi route definitions into calls on the application
service boundary. It owns route registration, query binding, request validation,
forecast day-slice filtering, outlook active/location filter construction,
response envelopes, and template names.
forecast day-slice filtering, outlook active filter construction,
alert active-time selection, response envelopes, and template names.
## Inputs and Outputs
@@ -35,7 +35,8 @@ The adapter may:
- call the `Service` interface;
- choose the presenter function for an endpoint;
- filter forecast copies for `/today` and `/tomorrow`;
- construct outlook active/location filters.
- pass the current UTC instant to active-alert application filtering;
- construct outlook active filters.
The adapter must not:
@@ -61,9 +62,9 @@ declared in endpoint definitions, but `templates.base_dir` is loaded by feedapi.
## State
The adapter has no durable state. `forecastNow` and `outlookNow` are
package-level state only to make time-dependent endpoint tests deterministic.
Do not add request caches or cross-request mutable state here.
The adapter has no durable state. `forecastNow`, `alertNow`, and `outlookNow`
are package-level state only to make time-dependent endpoint tests
deterministic. Do not add request caches or cross-request mutable state here.
## Route Registry
@@ -98,10 +99,8 @@ common query values are lowercased and trimmed before binding where applicable.
available only through binders used by forecast, discussion, weather story, and
outlook routes.
Outlook routes accept `day`, `outlookType`, and, except for
`/outlooks/convective/location`, `containsLocation`. The location route always
adds `containsLocation=true` after binding and rejects an explicit
`containsLocation` query value.
Outlook routes accept `day` and `outlookType`. `containsLocation` is a response
field only and is rejected as a request parameter.
## Timezone Parsing
@@ -131,18 +130,30 @@ Filtering behavior:
The package variable `forecastNow` exists so endpoint tests can make day-slice
behavior deterministic.
## Alert Active Time
`/alerts/active` uses the shared `format` and `units` binder. The handler calls
the application service with `alertNow().UTC()` so active alert filtering uses
the request-time instant while remaining deterministic in endpoint tests. The
application service prefers alert `ends` over `expires` when deciding whether an
alert has ended. The application service also suppresses alerts referenced by
another alert in the same latest run. This supersession rule uses alert
references from update and cancel messages, even when the referencing message is
not returned by `/alerts/active`.
## Outlook Filters
Outlook route filters are built at the HTTP boundary and passed to the
application service:
- `/outlooks/convective` uses only user-supplied filters;
- `/outlooks/convective/active` adds `ActiveAt=outlookNow().UTC()`;
- `/outlooks/convective/location` adds the same active timestamp and
`ContainsLocation=true`.
- `/outlooks/convective/active` adds `ActiveAt=outlookNow().UTC()`.
The package variable `outlookNow` exists so endpoint tests can make active and
location filtering deterministic.
The package variable `outlookNow` exists so endpoint tests can make active
filtering deterministic.
The application service returns filtered outlook copies and trims run-level
discussions to days represented by retained outlooks.
## Failure Behavior

View File

@@ -76,10 +76,13 @@ successful responses with `data: null`.
- `LatestObservation`: latest row from `observations`, then present-weather
rows from `observation_present_weather`.
- `CurrentConditions`: aggregates recent rows from `observations` using the
application-provided observation window.
- `CurrentConditions`: reads an aggregate row from recent `observations` using
the application-provided observation window, then reads the latest
condition-code candidate per `event_source` in the same window.
- `LatestAlertRun`: latest row from `alert_runs`, then child `alerts` and
`alert_references`.
`alert_references`. This is the latest stored alert snapshot. The repository
maps both `ends` and `expires`; active-time filtering is performed by the
application service.
- `LatestHourlyForecast`: latest `forecasts` row where `product = 'hourly'`,
then child `forecast_periods`.
- `LatestNarrativeForecast`: latest `forecasts` row where
@@ -90,7 +93,7 @@ successful responses with `data: null`.
`weather_stories`.
- `LatestWeatherStory`: latest individual row from `weather_stories`.
- `LatestConvectiveOutlookRun`: latest row from `outlook_runs`, then child
`outlooks`.
`outlooks` and `outlook_discussions`.
Latest parent rows are selected by descending weather timestamp and
`event_emitted_at` where that tie-breaker is available in the query.
@@ -105,7 +108,8 @@ Child queries preserve stored order:
- forecast periods by `period_index`;
- discussion key messages by `message_index`;
- weather stories by `story_index`;
- outlooks by `outlook_index`.
- outlooks by `outlook_index`;
- outlook discussions by `discussion_index`.
Alert references are attached after both alert and reference rows are loaded.
References are grouped by alert index and attached to their corresponding alert.

View File

@@ -118,9 +118,11 @@ response envelope so renderers can produce `data: null`.
- Discussions: full or focused payload shapes, section copy, key-message copy,
timezone conversion.
- Weather stories: run/story copy and timezone conversion.
- Convective outlooks: canonical model copy, pointer and geometry copy, and
timezone conversion. `units` is accepted by routes but ignored by the
presenter because outlook fields are not unit-bearing.
- Convective outlooks: canonical model copy, pointer and geometry copy,
run-level discussion copy, and timezone conversion for run, outlook, and
discussion timestamps. Outlook polygon prose is not handled by the presenter;
prose is carried by run-level discussions. `units` is accepted by routes but
ignored by the presenter because outlook fields are not unit-bearing.
## Templates

View File

@@ -91,8 +91,13 @@ build.
## Database Dependency
`weatherapi` expects weatherfeeder-compatible tables for observations, current
conditions aggregation, active alerts, forecasts, forecast discussions, and
weather stories. It only reads those tables.
conditions aggregation, active alerts, forecasts, forecast discussions, weather
stories, and convective outlooks. It only reads those tables.
Convective outlook endpoints require weatherfeeder's `weather.outlook.v2` table
shape, including `outlook_runs`, `outlooks`, and `outlook_discussions`. If
operators reset or recreate outlook tables during a weatherfeeder upgrade,
complete that weatherfeeder-side migration before starting `weatherapi`.
Operational ownership is split:
@@ -113,7 +118,7 @@ template named by each endpoint, with templates stored under `templates.base_dir
The repository includes templates for all implemented endpoint families:
observations, current conditions, active alerts, hourly forecasts, narrative
forecasts, forecast discussions, and weather stories.
forecasts, forecast discussions, weather stories, and convective outlooks.
If text rendering fails or returns an unsupported-format error, verify:

View File

@@ -100,7 +100,7 @@ Outputs are HTTP responses in JSON, XML, or text format. All public endpoint han
`weatherapi` owns no durable weather state. Its runtime state is limited to process memory, loaded configuration, HTTP server state, template registry, renderer registry, and database connection pools. Durable weather data and schema creation are external concerns owned by `weatherfeeder` and Postgres.
The API currently serves latest-resource views: latest observation, current conditions, latest active alerts run, latest hourly forecast, latest narrative forecast, latest forecast discussion, latest weather story run, latest individual weather story, and latest convective outlook run. Forecast `today` and `tomorrow` routes derive filtered copies from the latest run. Convective outlook active and location routes derive filtered copies from the latest run.
The API currently serves latest-resource views: latest observation, current conditions, latest hourly forecast, latest narrative forecast, latest forecast discussion, latest weather story run, latest individual weather story, and latest convective outlook run. Active alert, forecast `today` and `tomorrow`, and convective outlook active routes derive filtered copies from the latest stored run.
## Configuration and CLI Boundaries

View File

@@ -1,14 +0,0 @@
# SPC Convective Outlook API Roadmap
There are no active roadmap items for the SPC convective outlook API.
Current behavior is documented in:
- [`docs/api.md`](../api.md)
- [`docs/internal/http-adapter.md`](../internal/http-adapter.md)
- [`docs/internal/presenters.md`](../internal/presenters.md)
- [`docs/internal/postgres-repository.md`](../internal/postgres-repository.md)
- [`docs/integrations/weatherfeeder-postgres.md`](../integrations/weatherfeeder-postgres.md)
Future changes to convective outlook behavior should be proposed in a new
roadmap entry before implementation.

View File

@@ -1,14 +0,0 @@
# SPC Convective Outlook API Roadmap
There are no active roadmap items for the SPC convective outlook API.
Current behavior is documented in:
- [`docs/api.md`](../api.md)
- [`docs/internal/http-adapter.md`](../internal/http-adapter.md)
- [`docs/internal/presenters.md`](../internal/presenters.md)
- [`docs/internal/postgres-repository.md`](../internal/postgres-repository.md)
- [`docs/integrations/weatherfeeder-postgres.md`](../integrations/weatherfeeder-postgres.md)
Future changes to convective outlook behavior should be proposed in a new
roadmap entry before implementation.

View File

@@ -109,7 +109,7 @@ Likely causes:
- unknown query parameter;
- `precision` outside `0` through `2`, or not an integer;
- `precision` used on alerts, discussions, or weather stories;
- `precision` used on alerts, discussions, weather stories, or outlooks;
- `tz` used on observations, current conditions, or alerts;
- invalid timezone value;
- both `tz` and `TZ` are present with different values.
@@ -187,6 +187,10 @@ Safe fix: verify weatherfeeder is running and writing to the same database that
`weatherapi` uses as its first configured database. For current conditions,
wait for recent observations or inspect weatherfeeder ingestion.
For convective outlooks, also verify that weatherfeeder has applied its
`weather.outlook.v2` table shape and is writing `outlook_runs`, `outlooks`, and
`outlook_discussions`.
## Forecast Day Routes Return Empty `periods`
Symptom: `/forecast/hourly/today`, `/forecast/hourly/tomorrow`,

View File

@@ -15,8 +15,8 @@ GET {{baseUrl}}/alerts/active?format=text
GET {{baseUrl}}/outlooks/convective?day=1&outlookType=categorical
Accept: application/json
### Active convective outlooks for the configured location as text
GET {{baseUrl}}/outlooks/convective/location?format=text&tz=CDT
### Active convective outlooks as text
GET {{baseUrl}}/outlooks/convective/active?format=text&tz=CDT
### Hourly forecast in US units
GET {{baseUrl}}/forecast/hourly?units=us&precision=1&tz=Chicago

2
go.mod
View File

@@ -4,7 +4,7 @@ go 1.25.5
require (
gitea.maximumdirect.net/ejr/feedapi v0.1.0
gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.1
github.com/lib/pq v1.10.9
)

4
go.sum
View File

@@ -1,7 +1,7 @@
gitea.maximumdirect.net/ejr/feedapi v0.1.0 h1:ZB5QWKD5DPFV3P7vyeJqXPMcSWN9qHkDUHw1LgN9hwY=
gitea.maximumdirect.net/ejr/feedapi v0.1.0/go.mod h1:3fIaFFx4ywt0TWbN8DIIBAHJn7ZQUm6PNcceqRgy3bw=
gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0 h1:4hRiktLMFS8Arl98j21YYskXT8RHHQGFDPJxOF3CpwU=
gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0/go.mod h1:VVtuwrbddWdUu21ovCSSojhH5J9P6kk0/dfnFqC4/Lw=
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.1 h1:dZYDpOd0vEIk6QljsrhFyrOY0Lt4WyEoazhqDFIZKNQ=
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.1/go.mod h1:VVtuwrbddWdUu21ovCSSojhH5J9P6kk0/dfnFqC4/Lw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View File

@@ -4,6 +4,7 @@ package httpapi
import (
"context"
"time"
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
"gitea.maximumdirect.net/ejr/feedapi/render"
@@ -11,12 +12,14 @@ import (
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
)
var alertNow = time.Now
func alertsDefinition(svc Service) endpoint.Definition {
return endpoint.GET(
"/alerts/active",
bindQuery,
func(ctx context.Context, req queryRequest) (any, error) {
run, err := svc.LatestAlertRun(ctx)
run, err := svc.LatestActiveAlertRun(ctx, alertNow().UTC())
if err != nil {
return nil, err
}

View File

@@ -30,6 +30,7 @@ type fakeService struct {
weatherStoryRun *model.WeatherStoryRun
weatherStory *model.WeatherStory
alerts *model.WeatherAlertRun
activeAlertAt []time.Time
outlookRun *model.WeatherOutlookRun
outlookFilters []app.OutlookFilter
conditions *app.CurrentConditions
@@ -64,6 +65,11 @@ func (s *fakeService) LatestAlertRun(context.Context) (*model.WeatherAlertRun, e
return s.alerts, s.err
}
func (s *fakeService) LatestActiveAlertRun(_ context.Context, activeAt time.Time) (*model.WeatherAlertRun, error) {
s.activeAlertAt = append(s.activeAlertAt, activeAt)
return s.alerts, s.err
}
func (s *fakeService) LatestConvectiveOutlook(_ context.Context, filter app.OutlookFilter) (*model.WeatherOutlookRun, error) {
s.outlookFilters = append(s.outlookFilters, filter)
return s.outlookRun, s.err
@@ -73,6 +79,46 @@ func (s *fakeService) CurrentConditions(context.Context) (*app.CurrentConditions
return s.conditions, s.err
}
type alertRepository struct {
alerts *model.WeatherAlertRun
}
func (r *alertRepository) LatestObservation(context.Context) (*model.WeatherObservation, error) {
return nil, nil
}
func (r *alertRepository) LatestHourlyForecast(context.Context) (*model.WeatherForecastRun, error) {
return nil, nil
}
func (r *alertRepository) LatestNarrativeForecast(context.Context) (*model.WeatherForecastRun, error) {
return nil, nil
}
func (r *alertRepository) LatestForecastDiscussion(context.Context) (*model.WeatherForecastDiscussion, error) {
return nil, nil
}
func (r *alertRepository) LatestWeatherStoryRun(context.Context) (*model.WeatherStoryRun, error) {
return nil, nil
}
func (r *alertRepository) LatestWeatherStory(context.Context) (*model.WeatherStory, error) {
return nil, nil
}
func (r *alertRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
return r.alerts, nil
}
func (r *alertRepository) LatestConvectiveOutlookRun(context.Context) (*model.WeatherOutlookRun, error) {
return nil, nil
}
func (r *alertRepository) CurrentConditions(context.Context, int) (*app.CurrentConditions, error) {
return nil, nil
}
func TestObservationsRejectUnknownQueryParameter(t *testing.T) {
h := newHandler(t, &fakeService{}, "/observations")
@@ -278,12 +324,14 @@ func TestObservationUSUnitsChangesFieldNames(t *testing.T) {
}
func TestAlertsUSUnitsKeepSchema(t *testing.T) {
ends := time.Date(2026, 6, 11, 14, 0, 0, 0, time.UTC)
h := newHandler(t, &fakeService{
alerts: &model.WeatherAlertRun{
AsOf: time.Now().UTC(),
Alerts: []model.WeatherAlert{{
ID: "abc",
Headline: "A headline",
Ends: &ends,
}},
},
}, "/alerts/active")
@@ -313,6 +361,160 @@ func TestAlertsUSUnitsKeepSchema(t *testing.T) {
if first["id"] != "abc" {
t.Fatalf("expected alert id abc, got %#v", first["id"])
}
if _, ok := first["ends"].(string); !ok {
t.Fatalf("expected alert ends string, got %#v", first["ends"])
}
}
func TestAlertsRouteRegistered(t *testing.T) {
def := definitionForPath(t, Definitions(&fakeService{}), "/alerts/active")
if len(def.Methods) != 1 || def.Methods[0] != http.MethodGet {
t.Fatalf("expected GET definition, got %+v", def.Methods)
}
}
func TestAlertsHandlerPassesCurrentUTCInstant(t *testing.T) {
now := time.Date(2026, 6, 11, 7, 30, 0, 0, time.FixedZone("LOCAL", -5*60*60))
setAlertNowForTest(t, now)
svc := &fakeService{
alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()},
}
h := newHandler(t, svc, "/alerts/active")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/alerts/active", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if len(svc.activeAlertAt) != 1 {
t.Fatalf("expected one active-alert service call, got %d", len(svc.activeAlertAt))
}
want := now.UTC()
if !svc.activeAlertAt[0].Equal(want) {
t.Fatalf("expected activeAt %v, got %v", want, svc.activeAlertAt[0])
}
if svc.activeAlertAt[0].Location() != time.UTC {
t.Fatalf("expected UTC location, got %v", svc.activeAlertAt[0].Location())
}
}
func TestAlertsNoDataReturnsNullEnvelopeData(t *testing.T) {
h := newHandler(t, &fakeService{}, "/alerts/active")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/alerts/active", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload struct {
Data *json.RawMessage `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode envelope: %v", err)
}
if payload.Data != nil {
t.Fatalf("expected data null, got %s", string(*payload.Data))
}
}
func TestAlertsTextRendersNoData(t *testing.T) {
h := newHandler(t, &fakeService{}, "/alerts/active")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/alerts/active?format=text", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), "No active alerts data available.") {
t.Fatalf("expected no-data alert text, got %q", w.Body.String())
}
}
func TestAlertsTextRendersEmptyActiveSet(t *testing.T) {
h := newHandler(t, &fakeService{
alerts: &model.WeatherAlertRun{
AsOf: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
Alerts: []model.WeatherAlert{},
},
}, "/alerts/active")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/alerts/active?format=text", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), "Alerts: 0") {
t.Fatalf("expected empty alert count in text body, got %q", w.Body.String())
}
}
func TestAlertsTextOmitsInactiveAlertsAfterServiceFiltering(t *testing.T) {
activeAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
setAlertNowForTest(t, activeAt)
effective := activeAt.Add(-1 * time.Hour)
activeEnds := activeAt.Add(1 * time.Hour)
expiredEndsAtBoundary := activeAt
expiredProviderMetadata := activeAt.Add(-30 * time.Minute)
activeProviderMetadata := activeAt.Add(30 * time.Minute)
repo := &alertRepository{
alerts: &model.WeatherAlertRun{
AsOf: activeAt,
Alerts: []model.WeatherAlert{
{
ID: "active-alert",
Headline: "Active warning",
MessageType: "Alert",
Effective: &effective,
Ends: &activeEnds,
Expires: &expiredProviderMetadata,
},
{
ID: "expired-alert",
Headline: "Expired warning",
MessageType: "Alert",
Effective: &effective,
Ends: &expiredEndsAtBoundary,
Expires: &activeProviderMetadata,
},
{
ID: "canceled-alert",
Headline: "Canceled warning",
MessageType: " cancel ",
Effective: &effective,
Ends: &activeEnds,
},
},
},
}
h := newHandler(t, app.NewService(repo), "/alerts/active")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/alerts/active?format=text", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
for _, want := range []string{"Alerts: 1", "active-alert", "Active warning", "Ends:"} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q in text body, got %q", want, body)
}
}
for _, notWant := range []string{"expired-alert", "Expired warning", "canceled-alert", "Canceled warning"} {
if strings.Contains(body, notWant) {
t.Fatalf("did not expect %q in text body, got %q", notWant, body)
}
}
}
func TestObservationUSUnitsWithXMLFormat(t *testing.T) {
@@ -1159,6 +1361,9 @@ func TestCurrentConditionsMetricDefaultJSON(t *testing.T) {
if payload.Data["conditionText"] != "Rain" {
t.Fatalf("expected conditionText Rain, got %#v", payload.Data["conditionText"])
}
if payload.Data["conditionCode"] != float64(63) {
t.Fatalf("expected conditionCode 63, got %#v", payload.Data["conditionCode"])
}
}
func TestCurrentConditionsUSJSON(t *testing.T) {
@@ -1224,6 +1429,9 @@ func TestCurrentConditionsXMLAndTextFormats(t *testing.T) {
if !strings.Contains(w.Body.String(), "temperatureF") {
t.Fatalf("expected US field temperatureF in XML payload: %s", w.Body.String())
}
if !strings.Contains(w.Body.String(), "<conditionCode>2</conditionCode>") {
t.Fatalf("expected conditionCode in XML payload: %s", w.Body.String())
}
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/conditions/current?format=TEXT", nil)
@@ -1235,7 +1443,7 @@ func TestCurrentConditionsXMLAndTextFormats(t *testing.T) {
if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") {
t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
}
if !strings.Contains(w.Body.String(), "Conditions text") {
if !strings.Contains(w.Body.String(), "Condition Code: 2") {
t.Fatalf("expected rendered text template body, got %q", w.Body.String())
}
}
@@ -1265,17 +1473,24 @@ func TestPrecisionValidationRange(t *testing.T) {
}
}
func TestAlertsRejectPrecisionQueryParameter(t *testing.T) {
func TestAlertsRejectUnsupportedQueryParameters(t *testing.T) {
h := newHandler(t, &fakeService{
alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()},
}, "/alerts/active")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/alerts/active?precision=1", nil)
h.ServeHTTP(w, req)
for _, rawURL := range []string{
"/alerts/active?precision=1",
"/alerts/active?tz=CDT",
"/alerts/active?TZ=CDT",
"/alerts/active?bogus=1",
} {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, rawURL, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
if w.Code != http.StatusBadRequest {
t.Fatalf("%s: expected 400, got %d", rawURL, w.Code)
}
}
}
@@ -1284,13 +1499,15 @@ func TestOutlookRoutesRegistered(t *testing.T) {
for _, path := range []string{
"/outlooks/convective",
"/outlooks/convective/active",
"/outlooks/convective/location",
} {
def := definitionForPath(t, defs, path)
if len(def.Methods) != 1 || def.Methods[0] != http.MethodGet {
t.Fatalf("%s: expected GET definition, got %+v", path, def.Methods)
}
}
if endpointExists(defs, "/outlooks/convective/location") {
t.Fatal("expected /outlooks/convective/location to be removed")
}
}
func TestOutlookRoutesJSONSuccess(t *testing.T) {
@@ -1299,7 +1516,6 @@ func TestOutlookRoutesJSONSuccess(t *testing.T) {
for _, path := range []string{
"/outlooks/convective",
"/outlooks/convective/active",
"/outlooks/convective/location",
} {
t.Run(path, func(t *testing.T) {
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, path)
@@ -1314,10 +1530,9 @@ func TestOutlookRoutesJSONSuccess(t *testing.T) {
var payload struct {
Data struct {
LocationID string `json:"locationId"`
Outlooks []struct {
ID string `json:"id"`
} `json:"outlooks"`
LocationID string `json:"locationId"`
Outlooks []map[string]any `json:"outlooks"`
Discussions []outlookDiscussionCheck `json:"discussions"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
@@ -1326,9 +1541,20 @@ func TestOutlookRoutesJSONSuccess(t *testing.T) {
if payload.Data.LocationID != "stl" {
t.Fatalf("expected locationId stl, got %q", payload.Data.LocationID)
}
if len(payload.Data.Outlooks) != 1 || payload.Data.Outlooks[0].ID != "cat-1" {
if len(payload.Data.Outlooks) != 1 || payload.Data.Outlooks[0]["id"] != "cat-1" {
t.Fatalf("unexpected outlooks payload: %+v", payload.Data.Outlooks)
}
for _, field := range []string{"headline", "summary", "discussion"} {
if _, ok := payload.Data.Outlooks[0][field]; ok {
t.Fatalf("expected outlook polygon to omit %s, got %+v", field, payload.Data.Outlooks[0])
}
}
if len(payload.Data.Discussions) != 1 {
t.Fatalf("expected one discussion, got %+v", payload.Data.Discussions)
}
if payload.Data.Discussions[0].Day != 1 || payload.Data.Discussions[0].Headline != "Day 1 headline" {
t.Fatalf("unexpected discussions payload: %+v", payload.Data.Discussions)
}
})
}
}
@@ -1369,7 +1595,8 @@ func TestOutlookTextResponseUsesTemplate(t *testing.T) {
t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
}
body := w.Body.String()
if !strings.Contains(body, "Convective Outlook") || !strings.Contains(body, "Outlooks: 1") {
if !strings.Contains(body, "Convective Outlook") || !strings.Contains(body, "Outlooks: 1") ||
!strings.Contains(body, "Discussions: 1") || !strings.Contains(body, "Day 1 discussion") {
t.Fatalf("expected outlook text template body, got %q", body)
}
}
@@ -1390,11 +1617,15 @@ func TestOutlookXMLResponseRenders(t *testing.T) {
if !strings.Contains(w.Body.String(), "<LocationID>stl</LocationID>") {
t.Fatalf("expected outlook XML payload, got %q", w.Body.String())
}
if !strings.Contains(w.Body.String(), "<Discussions>") || !strings.Contains(w.Body.String(), "<Headline>Day 1 headline</Headline>") {
t.Fatalf("expected outlook XML discussions, got %q", w.Body.String())
}
}
func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
run := testOutlookRun()
run.Outlooks = []model.WeatherOutlook{}
run.Discussions = []model.WeatherOutlookDiscussion{}
h := newHandler(t, &fakeService{outlookRun: run}, "/outlooks/convective")
w := httptest.NewRecorder()
@@ -1407,8 +1638,9 @@ func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
var payload struct {
Data struct {
LocationID string `json:"locationId"`
Outlooks []model.WeatherOutlook `json:"outlooks"`
LocationID string `json:"locationId"`
Outlooks []model.WeatherOutlook `json:"outlooks"`
Discussions []model.WeatherOutlookDiscussion `json:"discussions"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
@@ -1420,6 +1652,39 @@ func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
if payload.Data.Outlooks == nil || len(payload.Data.Outlooks) != 0 {
t.Fatalf("expected empty outlooks slice, got %+v", payload.Data.Outlooks)
}
if payload.Data.Discussions == nil || len(payload.Data.Discussions) != 0 {
t.Fatalf("expected empty discussions slice, got %+v", payload.Data.Discussions)
}
}
func TestOutlookTimezoneQuery(t *testing.T) {
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, "/outlooks/convective")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?tz=CDT", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload outlookTimePayload
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode outlook payload: %v", err)
}
assertOffsetSeconds(t, payload.Data.AsOf, -5*60*60)
assertOffsetSeconds(t, *payload.Data.IssuedAt, -5*60*60)
if len(payload.Data.Outlooks) != 1 {
t.Fatalf("expected one outlook, got %+v", payload.Data.Outlooks)
}
assertOffsetSeconds(t, payload.Data.Outlooks[0].ValidFrom, -5*60*60)
assertOffsetSeconds(t, payload.Data.Outlooks[0].ValidTo, -5*60*60)
assertOffsetSeconds(t, payload.Data.Outlooks[0].IssuedAt, -5*60*60)
assertOffsetSeconds(t, payload.Data.Outlooks[0].ExpiresAt, -5*60*60)
if len(payload.Data.Discussions) != 1 || payload.Data.Discussions[0].UpdatedAt == nil {
t.Fatalf("expected one discussion with updatedAt, got %+v", payload.Data.Discussions)
}
assertOffsetSeconds(t, *payload.Data.Discussions[0].UpdatedAt, -5*60*60)
}
func TestOutlookQueryParamsConstructFilter(t *testing.T) {
@@ -1427,7 +1692,7 @@ func TestOutlookQueryParamsConstructFilter(t *testing.T) {
h := newHandler(t, svc, "/outlooks/convective")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?day=2&outlookType=Tornado&containsLocation=true&tz=CDT&units=US", nil)
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?day=2&outlookType=Tornado&tz=CDT&units=US", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
@@ -1443,15 +1708,12 @@ func TestOutlookQueryParamsConstructFilter(t *testing.T) {
if filter.OutlookType != "tornado" {
t.Fatalf("expected outlookType tornado, got %q", filter.OutlookType)
}
if filter.ContainsLocation == nil || !*filter.ContainsLocation {
t.Fatalf("expected containsLocation true, got %+v", filter.ContainsLocation)
}
if filter.ActiveAt != nil {
t.Fatalf("expected no active filter, got %v", filter.ActiveAt)
}
}
func TestOutlookActiveAndLocationFiltersUseNow(t *testing.T) {
func TestOutlookActiveFilterUsesNow(t *testing.T) {
now := time.Date(2026, 6, 11, 15, 30, 0, 0, time.FixedZone("CDT", -5*3600))
setOutlookNowForTest(t, now)
@@ -1467,28 +1729,6 @@ func TestOutlookActiveAndLocationFiltersUseNow(t *testing.T) {
if activeFilter.ActiveAt == nil || !activeFilter.ActiveAt.Equal(now.UTC()) {
t.Fatalf("expected activeAt %s, got %v", now.UTC(), activeFilter.ActiveAt)
}
if activeFilter.ContainsLocation != nil {
t.Fatalf("expected active route not to force containsLocation, got %+v", activeFilter.ContainsLocation)
}
locationSvc := &fakeService{outlookRun: testOutlookRun()}
locationHandler := newHandler(t, locationSvc, "/outlooks/convective/location")
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/outlooks/convective/location?outlookType=hail", nil)
locationHandler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected location 200, got %d", w.Code)
}
locationFilter := locationSvc.outlookFilters[0]
if locationFilter.ActiveAt == nil || !locationFilter.ActiveAt.Equal(now.UTC()) {
t.Fatalf("expected location activeAt %s, got %v", now.UTC(), locationFilter.ActiveAt)
}
if locationFilter.ContainsLocation == nil || !*locationFilter.ContainsLocation {
t.Fatalf("expected location route to force containsLocation true, got %+v", locationFilter.ContainsLocation)
}
if locationFilter.OutlookType != "hail" {
t.Fatalf("expected outlookType hail, got %q", locationFilter.OutlookType)
}
}
func TestOutlookInvalidQueryParamsReturnBadRequest(t *testing.T) {
@@ -1499,10 +1739,10 @@ func TestOutlookInvalidQueryParamsReturnBadRequest(t *testing.T) {
"/outlooks/convective?day=4",
"/outlooks/convective?day=two",
"/outlooks/convective?outlookType=snow",
"/outlooks/convective?containsLocation=maybe",
"/outlooks/convective?containsLocation=true",
"/outlooks/convective/active?containsLocation=true",
"/outlooks/convective?tz=not-a-timezone",
"/outlooks/convective?tz=CDT&TZ=EST",
"/outlooks/convective/location?containsLocation=true",
} {
t.Run(rawURL, func(t *testing.T) {
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, strings.Split(rawURL, "?")[0])
@@ -2191,6 +2431,15 @@ func definitionForPath(t *testing.T, defs []endpoint.Definition, path string) en
return endpoint.Definition{}
}
func endpointExists(defs []endpoint.Definition, path string) bool {
for _, def := range defs {
if def.Path == path {
return true
}
}
return false
}
func testRenderers(t *testing.T) *render.Registry {
t.Helper()
@@ -2211,11 +2460,11 @@ func testRenderers(t *testing.T) *render.Registry {
"discussion_long_term.txt.tmpl": "Forecast Discussion Long Term",
"forecast_hourly.txt.tmpl": "Forecast text",
"forecast_narrative.txt.tmpl": "Narrative Forecast",
"outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}{{else}}No convective outlook data available.{{end}}",
"outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}\nDiscussions: {{len .Data.Discussions}}{{range .Data.Discussions}}\nDiscussion: {{.Discussion}}{{end}}{{else}}No convective outlook data available.{{end}}",
"weatherstories.txt.tmpl": "Weather Stories",
"weatherstories_latest.txt.tmpl": "Latest Weather Story",
"alerts_active.txt.tmpl": "Alerts text",
"conditions_current.txt.tmpl": "Conditions text",
"alerts_active.txt.tmpl": "{{if .Data}}Active Alerts\nAlerts: {{len .Data.Alerts}}{{range .Data.Alerts}}\n{{.ID}}{{if .Headline}}\nHeadline: {{.Headline}}{{end}}{{if .Ends}}\nEnds: {{.Ends}}{{end}}{{end}}{{else}}No active alerts data available.{{end}}",
"conditions_current.txt.tmpl": "Condition Code: {{.Data.ConditionCode}}",
} {
tmpl, err := template.New(name).Parse(body)
if err != nil {
@@ -2248,8 +2497,16 @@ func setOutlookNowForTest(t *testing.T, now time.Time) {
t.Cleanup(func() { outlookNow = original })
}
func setAlertNowForTest(t *testing.T, now time.Time) {
t.Helper()
original := alertNow
alertNow = func() time.Time { return now }
t.Cleanup(func() { alertNow = original })
}
func testOutlookRun() *model.WeatherOutlookRun {
issuedAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
discussionUpdatedAt := issuedAt.Add(30 * time.Minute)
return &model.WeatherOutlookRun{
LocationID: "stl",
LocationName: "St. Louis",
@@ -2270,6 +2527,13 @@ func testOutlookRun() *model.WeatherOutlookRun {
ContainsLocation: true,
Geometry: []byte(`{"type":"Point","coordinates":[-90.2,38.6]}`),
}},
Discussions: []model.WeatherOutlookDiscussion{{
Day: 1,
Headline: "Day 1 headline",
Summary: "Day 1 summary",
Discussion: "Day 1 discussion",
UpdatedAt: &discussionUpdatedAt,
}},
}
}
@@ -2343,6 +2607,27 @@ type discussionFocusedTimePayload struct {
} `json:"data"`
}
type outlookDiscussionCheck struct {
Day int `json:"day"`
Headline string `json:"headline"`
}
type outlookTimePayload struct {
Data struct {
AsOf time.Time `json:"asOf"`
IssuedAt *time.Time `json:"issuedAt"`
Outlooks []struct {
ValidFrom time.Time `json:"validFrom"`
ValidTo time.Time `json:"validTo"`
IssuedAt time.Time `json:"issuedAt"`
ExpiresAt time.Time `json:"expiresAt"`
} `json:"outlooks"`
Discussions []struct {
UpdatedAt *time.Time `json:"updatedAt"`
} `json:"discussions"`
} `json:"data"`
}
func decodeForecastTimePayload(t *testing.T, w *httptest.ResponseRecorder) forecastTimePayload {
t.Helper()

View File

@@ -18,7 +18,6 @@ type outlookFilterMode int
const (
outlookFilterUser outlookFilterMode = iota
outlookFilterActive
outlookFilterLocation
)
var outlookNow = time.Now
@@ -27,7 +26,6 @@ func outlookDefinitions(svc Service) []endpoint.Definition {
return []endpoint.Definition{
outlookDefinition("/outlooks/convective", outlookFilterUser, bindOutlookQuery, svc),
outlookDefinition("/outlooks/convective/active", outlookFilterActive, bindOutlookQuery, svc),
outlookDefinition("/outlooks/convective/location", outlookFilterLocation, bindOutlookLocationQuery, svc),
}
}
@@ -42,14 +40,10 @@ func outlookDefinition(
binder,
func(ctx context.Context, req outlookQueryRequest) (any, error) {
filter := req.Filter
if mode == outlookFilterActive || mode == outlookFilterLocation {
if mode == outlookFilterActive {
activeAt := outlookNow().UTC()
filter.ActiveAt = &activeAt
}
if mode == outlookFilterLocation {
containsLocation := true
filter.ContainsLocation = &containsLocation
}
run, err := svc.LatestConvectiveOutlook(ctx, filter)
if err != nil {

View File

@@ -4,25 +4,27 @@ package presenter
import (
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
)
// CurrentConditionsResponse is the response shape for /conditions/current.
// Unit-bearing fields are populated according to the requested unit mode.
type CurrentConditionsResponse struct {
TemperatureC *float64 `json:"temperatureC,omitempty" xml:"temperatureC,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty" xml:"apparentTemperatureC,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty" xml:"dewpointC,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty" xml:"windSpeedKmh,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"`
ConditionText string `json:"conditionText,omitempty" xml:"conditionText,omitempty"`
IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"`
IsDayText string `json:"-" xml:"-"`
TemperatureC *float64 `json:"temperatureC,omitempty" xml:"temperatureC,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty" xml:"apparentTemperatureC,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty" xml:"dewpointC,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty" xml:"windSpeedKmh,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"`
ConditionCode model.WMOCode `json:"conditionCode" xml:"conditionCode"`
ConditionText string `json:"conditionText,omitempty" xml:"conditionText,omitempty"`
IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"`
IsDayText string `json:"-" xml:"-"`
}
func CurrentConditionsPayload(conditions *app.CurrentConditions, units Units, precision int) any {
@@ -33,6 +35,7 @@ func CurrentConditionsPayload(conditions *app.CurrentConditions, units Units, pr
out := CurrentConditionsResponse{
RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(conditions.RelativeHumidityPercent), precision),
WindDirectionDegrees: roundedPtr(copyFloat64Ptr(conditions.WindDirectionDegrees), precision),
ConditionCode: conditions.ConditionCode,
ConditionText: standards.WMOText(conditions.ConditionCode, conditions.IsDay),
IsDay: copyBoolPtr(conditions.IsDay),
IsDayText: boolText(conditions.IsDay),

View File

@@ -22,10 +22,14 @@ func OutlookRunPayload(run *model.WeatherOutlookRun, _ Units, tz *time.Location)
AsOf: inLocationTime(run.AsOf, tz),
IssuedAt: inLocationTimePtr(run.IssuedAt, tz),
Outlooks: make([]model.WeatherOutlook, 0, len(run.Outlooks)),
Discussions: make([]model.WeatherOutlookDiscussion, 0, len(run.Discussions)),
}
for _, outlook := range run.Outlooks {
out.Outlooks = append(out.Outlooks, copyOutlook(outlook, tz))
}
for _, discussion := range run.Discussions {
out.Discussions = append(out.Discussions, copyOutlookDiscussion(discussion, tz))
}
return &out
}
@@ -44,9 +48,6 @@ func copyOutlook(outlook model.WeatherOutlook, tz *time.Location) model.WeatherO
IssuedAt: inLocationTime(outlook.IssuedAt, tz),
ExpiresAt: inLocationTime(outlook.ExpiresAt, tz),
Forecaster: outlook.Forecaster,
Headline: outlook.Headline,
Summary: outlook.Summary,
Discussion: outlook.Discussion,
SourceURL: outlook.SourceURL,
ImageURL: outlook.ImageURL,
ContainsLocation: outlook.ContainsLocation,
@@ -57,6 +58,16 @@ func copyOutlook(outlook model.WeatherOutlook, tz *time.Location) model.WeatherO
return out
}
func copyOutlookDiscussion(discussion model.WeatherOutlookDiscussion, tz *time.Location) model.WeatherOutlookDiscussion {
return model.WeatherOutlookDiscussion{
Day: discussion.Day,
Headline: discussion.Headline,
Summary: discussion.Summary,
Discussion: discussion.Discussion,
UpdatedAt: inLocationTimePtr(discussion.UpdatedAt, tz),
}
}
func copyIntPtr(v *int) *int {
if v == nil {
return nil

View File

@@ -337,6 +337,22 @@ func TestMetricCopyAndNilHandling(t *testing.T) {
}
}
func TestAlertsPayloadPassThrough(t *testing.T) {
run := &model.WeatherAlertRun{
LocationID: "stl",
AsOf: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
Alerts: []model.WeatherAlert{{
ID: "alert-1",
Headline: "Storm warning",
}},
}
payload := AlertsPayload(run, UnitsUS)
if payload != run {
t.Fatalf("expected alerts payload to pass through input run")
}
}
func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
conditions := &app.CurrentConditions{
TemperatureC: float64Ptr(20),
@@ -362,6 +378,9 @@ func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
if metric.ConditionText != "Sunny" {
t.Fatalf("expected condition text Sunny, got %q", metric.ConditionText)
}
if metric.ConditionCode != 0 {
t.Fatalf("expected condition code 0, got %d", metric.ConditionCode)
}
usPayload := CurrentConditionsPayload(conditions, UnitsUS, 2)
us, ok := usPayload.(CurrentConditionsResponse)
@@ -373,6 +392,9 @@ func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
if us.TemperatureC != nil || us.WindSpeedKmh != nil {
t.Fatalf("expected metric fields omitted for US payload")
}
if us.ConditionCode != 0 {
t.Fatalf("expected condition code 0, got %d", us.ConditionCode)
}
}
func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) {
@@ -440,6 +462,7 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
loc := time.FixedZone("UTC-05:00", -5*60*60)
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
issuedAt := asOf.Add(-1 * time.Hour)
discussionUpdatedAt := asOf.Add(-30 * time.Minute)
severityRank := 5
latitude := 38.627123
longitude := -90.199456
@@ -465,14 +488,18 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
IssuedAt: issuedAt,
ExpiresAt: asOf.Add(6 * time.Hour),
Forecaster: "DIAL",
Headline: "Severe storms possible",
Summary: "Scattered severe storms are possible.",
Discussion: "Discussion text.",
SourceURL: "https://example.test/source",
ImageURL: "https://example.test/image.png",
ContainsLocation: true,
Geometry: geometry,
}},
Discussions: []model.WeatherOutlookDiscussion{{
Day: 1,
Headline: "Severe storms possible",
Summary: "Scattered severe storms are possible.",
Discussion: "Discussion text.",
UpdatedAt: &discussionUpdatedAt,
}},
}
payload := OutlookRunPayload(run, UnitsUS, loc)
@@ -489,9 +516,15 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
if len(out.Outlooks) != 1 {
t.Fatalf("expected one outlook, got %d", len(out.Outlooks))
}
if len(out.Discussions) != 1 {
t.Fatalf("expected one discussion, got %d", len(out.Discussions))
}
if out.Outlooks[0].SeverityRank == run.Outlooks[0].SeverityRank {
t.Fatalf("expected severity rank pointer to be copied")
}
if out.Discussions[0].UpdatedAt == run.Discussions[0].UpdatedAt {
t.Fatalf("expected discussion updatedAt pointer to be copied")
}
if &out.Outlooks[0].Geometry[0] == &run.Outlooks[0].Geometry[0] {
t.Fatalf("expected geometry bytes to be copied")
}
@@ -502,6 +535,7 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
assertOffsetSeconds(t, out.Outlooks[0].ValidTo, -5*60*60)
assertOffsetSeconds(t, out.Outlooks[0].IssuedAt, -5*60*60)
assertOffsetSeconds(t, out.Outlooks[0].ExpiresAt, -5*60*60)
assertOffsetSeconds(t, *out.Discussions[0].UpdatedAt, -5*60*60)
if !out.AsOf.UTC().Equal(asOf) || !out.Outlooks[0].ValidFrom.UTC().Equal(asOf) {
t.Fatalf("expected timezone conversion to preserve instants")
}
@@ -515,12 +549,19 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
if string(out.Outlooks[0].Geometry) != string(geometry) {
t.Fatalf("expected geometry bytes preserved, got %s", out.Outlooks[0].Geometry)
}
if out.Discussions[0].Day != 1 || out.Discussions[0].Headline != "Severe storms possible" ||
out.Discussions[0].Summary != "Scattered severe storms are possible." ||
out.Discussions[0].Discussion != "Discussion text." {
t.Fatalf("expected discussion fields preserved, got %+v", out.Discussions[0])
}
*out.Latitude = 99
*out.Longitude = -99
*out.IssuedAt = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
*out.Outlooks[0].SeverityRank = 99
*out.Discussions[0].UpdatedAt = time.Date(2031, 1, 1, 0, 0, 0, 0, time.UTC)
out.Outlooks[0].Geometry[0] = '['
out.Discussions[0].Headline = "changed"
if *run.Latitude != latitude || *run.Longitude != longitude || !run.IssuedAt.Equal(issuedAt) {
t.Fatalf("expected source run pointers not to mutate")
}
@@ -530,8 +571,15 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
if string(run.Outlooks[0].Geometry) != string(geometry) {
t.Fatalf("expected source geometry not to mutate, got %s", run.Outlooks[0].Geometry)
}
if !run.Discussions[0].UpdatedAt.Equal(discussionUpdatedAt) {
t.Fatalf("expected source discussion updatedAt not to mutate, got %v", run.Discussions[0].UpdatedAt)
}
if run.Discussions[0].Headline != "Severe storms possible" {
t.Fatalf("expected source discussion headline not to mutate, got %q", run.Discussions[0].Headline)
}
assertOffsetSeconds(t, run.AsOf, 0)
assertOffsetSeconds(t, run.Outlooks[0].ValidFrom, 0)
assertOffsetSeconds(t, *run.Discussions[0].UpdatedAt, 0)
}
func float64Ptr(v float64) *float64 {

View File

@@ -145,29 +145,16 @@ func bindPrecisionQueryInternal(r *http.Request, allowTimezone bool) (precisionQ
}
func bindOutlookQuery(r *http.Request) (outlookQueryRequest, error) {
return bindOutlookQueryInternal(r, true)
}
func bindOutlookLocationQuery(r *http.Request) (outlookQueryRequest, error) {
return bindOutlookQueryInternal(r, false)
}
func bindOutlookQueryInternal(r *http.Request, allowContainsLocation bool) (outlookQueryRequest, error) {
normalizeCommonQueryValue(r, "units")
normalizeCommonQueryValue(r, "format")
normalizeCommonQueryValue(r, "outlookType")
allowedExtra := []string{"tz", "TZ", "day", "outlookType"}
if allowContainsLocation {
allowedExtra = append(allowedExtra, "containsLocation")
}
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
AllowUnits: true,
AllowFormat: true,
DefaultUnits: string(presenter.UnitsMetric),
RejectUnknown: true,
}, allowedExtra...)
}, "tz", "TZ", "day", "outlookType")
if err != nil {
return outlookQueryRequest{}, err
}
@@ -182,7 +169,7 @@ func bindOutlookQueryInternal(r *http.Request, allowContainsLocation bool) (outl
return outlookQueryRequest{}, err
}
filter, err := bindOutlookFilter(r, allowContainsLocation)
filter, err := bindOutlookFilter(r)
if err != nil {
return outlookQueryRequest{}, err
}
@@ -194,7 +181,7 @@ func bindOutlookQueryInternal(r *http.Request, allowContainsLocation bool) (outl
}, nil
}
func bindOutlookFilter(r *http.Request, allowContainsLocation bool) (app.OutlookFilter, error) {
func bindOutlookFilter(r *http.Request) (app.OutlookFilter, error) {
var filter app.OutlookFilter
if strings.TrimSpace(r.URL.Query().Get("day")) != "" {
@@ -218,16 +205,5 @@ func bindOutlookFilter(r *http.Request, allowContainsLocation bool) (app.Outlook
}
}
if strings.TrimSpace(r.URL.Query().Get("containsLocation")) != "" {
if !allowContainsLocation {
return app.OutlookFilter{}, apierrors.InvalidParameter("containsLocation is not allowed on this endpoint")
}
containsLocation, err := bind.OptionalBool(r, "containsLocation", false)
if err != nil {
return app.OutlookFilter{}, err
}
filter.ContainsLocation = &containsLocation
}
return filter, nil
}

View File

@@ -4,6 +4,7 @@ package httpapi
import (
"context"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
@@ -18,6 +19,7 @@ type Service interface {
LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error)
LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error)
LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error)
LatestActiveAlertRun(ctx context.Context, activeAt time.Time) (*model.WeatherAlertRun, error)
LatestConvectiveOutlook(ctx context.Context, filter app.OutlookFilter) (*model.WeatherOutlookRun, error)
CurrentConditions(ctx context.Context) (*app.CurrentConditions, error)
}

View File

@@ -33,6 +33,7 @@ func mapAlertRow(row alertRow) indexedAlert {
Sent: timePtr(row.Sent),
Effective: timePtr(row.Effective),
Onset: timePtr(row.Onset),
Ends: timePtr(row.Ends),
Expires: timePtr(row.Expires),
AreaDescription: stringValue(row.AreaDescription),
SenderName: stringValue(row.SenderName),

View File

@@ -33,6 +33,7 @@ SELECT
sent,
effective,
onset,
ends,
expires,
area_description,
sender_name

View File

@@ -70,6 +70,7 @@ func (r *Repository) loadAlerts(ctx context.Context, eventID string) ([]model.We
&row.Sent,
&row.Effective,
&row.Onset,
&row.Ends,
&row.Expires,
&row.AreaDescription,
&row.SenderName,

View File

@@ -35,6 +35,7 @@ type alertRow struct {
Sent sql.NullTime
Effective sql.NullTime
Onset sql.NullTime
Ends sql.NullTime
Expires sql.NullTime
AreaDescription sql.NullString
SenderName sql.NullString

View File

@@ -0,0 +1,107 @@
// conditions_codes.go selects current-conditions WMO codes from source candidates.
// Layer: adapters/outbound/postgres conditions feature.
package postgres
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
type currentConditionsConditionCodeCandidate struct {
EventSource string
ConditionCode model.WMOCode
}
type conditionCodeFamily int
const (
conditionCodeFamilyUnknown conditionCodeFamily = iota
conditionCodeFamilyClearOrCloud
conditionCodeFamilyFog
conditionCodeFamilyDrizzle
conditionCodeFamilyRain
conditionCodeFamilySnow
conditionCodeFamilyThunderstorm
)
var currentConditionsConditionCodeRankings = map[conditionCodeFamily][]model.WMOCode{
conditionCodeFamilyClearOrCloud: {0, 1, 2, 3},
conditionCodeFamilyFog: {45, 48},
conditionCodeFamilyDrizzle: {51, 53, 55, 56, 57},
conditionCodeFamilyRain: {61, 63, 65, 80, 81, 82, 66, 67},
conditionCodeFamilySnow: {71, 73, 75, 85, 86, 77},
conditionCodeFamilyThunderstorm: {95, 96, 99},
}
var currentConditionsConditionCodeFamilies = buildCurrentConditionsConditionCodeFamilies()
func buildCurrentConditionsConditionCodeFamilies() map[model.WMOCode]conditionCodeFamily {
families := make(map[model.WMOCode]conditionCodeFamily)
for family, ranking := range currentConditionsConditionCodeRankings {
for _, code := range ranking {
families[code] = family
}
}
return families
}
func selectCurrentConditionsConditionCode(candidates []currentConditionsConditionCodeCandidate) model.WMOCode {
seenSources := make(map[string]struct{})
familyCounts := make(map[conditionCodeFamily]int)
codeCounts := make(map[model.WMOCode]int)
for _, candidate := range candidates {
if _, seen := seenSources[candidate.EventSource]; seen {
continue
}
seenSources[candidate.EventSource] = struct{}{}
family, ok := currentConditionsConditionCodeFamilies[candidate.ConditionCode]
if !ok {
continue
}
familyCounts[family]++
codeCounts[candidate.ConditionCode]++
}
winningFamily, ok := currentConditionsWinningConditionCodeFamily(familyCounts)
if !ok {
return model.WMOUnknown
}
return currentConditionsWinningConditionCode(winningFamily, codeCounts)
}
func currentConditionsWinningConditionCodeFamily(counts map[conditionCodeFamily]int) (conditionCodeFamily, bool) {
winningFamily := conditionCodeFamilyUnknown
winningCount := 0
tied := false
for family, count := range counts {
if count > winningCount {
winningFamily = family
winningCount = count
tied = false
continue
}
if count == winningCount {
tied = true
}
}
if winningCount == 0 || tied {
return conditionCodeFamilyUnknown, false
}
return winningFamily, true
}
func currentConditionsWinningConditionCode(family conditionCodeFamily, counts map[model.WMOCode]int) model.WMOCode {
winningCode := model.WMOUnknown
winningCount := 0
for _, code := range currentConditionsConditionCodeRankings[family] {
if counts[code] > winningCount {
winningCode = code
winningCount = counts[code]
}
}
return winningCode
}

View File

@@ -0,0 +1,116 @@
// conditions_codes_test.go tests current-conditions WMO code selection.
// Layer: adapters/outbound/postgres conditions feature.
package postgres
import (
"testing"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func TestSelectCurrentConditionsConditionCode(t *testing.T) {
tests := []struct {
name string
candidates []currentConditionsConditionCodeCandidate
want model.WMOCode
}{
{
name: "clear cloud ranking breaks exact tie",
candidates: conditionCodeCandidates(
0,
1,
2,
),
want: 0,
},
{
name: "clear cloud family wins over thunderstorm",
candidates: conditionCodeCandidates(
1,
2,
95,
),
want: 1,
},
{
name: "tied families return unknown",
candidates: conditionCodeCandidates(
0,
95,
),
want: model.WMOUnknown,
},
{
name: "rain ranking breaks exact tie",
candidates: conditionCodeCandidates(
61,
63,
80,
),
want: 61,
},
{
name: "three tied families return unknown",
candidates: conditionCodeCandidates(
61,
95,
0,
),
want: model.WMOUnknown,
},
{
name: "single thunderstorm code wins",
candidates: conditionCodeCandidates(
95,
),
want: 95,
},
{
name: "unrecognized only returns unknown",
candidates: conditionCodeCandidates(
4,
100,
),
want: model.WMOUnknown,
},
{
name: "duplicate source only votes once",
candidates: []currentConditionsConditionCodeCandidate{
{EventSource: "source-1", ConditionCode: 95},
{EventSource: "source-1", ConditionCode: 95},
{EventSource: "source-2", ConditionCode: 0},
{EventSource: "source-3", ConditionCode: 0},
},
want: 0,
},
{
name: "exact code frequency wins before ranking",
candidates: []currentConditionsConditionCodeCandidate{
{EventSource: "source-1", ConditionCode: 61},
{EventSource: "source-2", ConditionCode: 63},
{EventSource: "source-3", ConditionCode: 63},
},
want: 63,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := selectCurrentConditionsConditionCode(tt.candidates)
if got != tt.want {
t.Fatalf("expected condition code %d, got %d", tt.want, got)
}
})
}
}
func conditionCodeCandidates(codes ...model.WMOCode) []currentConditionsConditionCodeCandidate {
candidates := make([]currentConditionsConditionCodeCandidate, 0, len(codes))
for i, code := range codes {
candidates = append(candidates, currentConditionsConditionCodeCandidate{
EventSource: string(rune('a' + i)),
ConditionCode: code,
})
}
return candidates
}

View File

@@ -7,16 +7,11 @@ import (
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func mapCurrentConditionsRow(row currentConditionsRow) *app.CurrentConditions {
func mapCurrentConditionsRow(row currentConditionsRow, conditionCode model.WMOCode) *app.CurrentConditions {
if row.SampleCount == 0 {
return nil
}
conditionCode := model.WMOUnknown
if row.ConditionCode.Valid {
conditionCode = model.WMOCode(row.ConditionCode.Int64)
}
return &app.CurrentConditions{
TemperatureC: float64Ptr(row.TemperatureC),
ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC),

View File

@@ -12,7 +12,6 @@ WITH windowed AS (
relative_humidity_percent,
wind_speed_kmh,
wind_direction_degrees,
condition_code,
is_day,
observed_at
FROM observations
@@ -39,7 +38,6 @@ SELECT
AVG(cosd(wind_direction_degrees))
)
END AS wind_direction_degrees,
MAX(condition_code) AS condition_code,
(
SELECT is_day
FROM windowed
@@ -47,4 +45,23 @@ SELECT
LIMIT 1
) AS is_day
FROM windowed`
queryCurrentConditionsConditionCodeCandidates = `
WITH ranked AS (
SELECT
event_source,
condition_code,
ROW_NUMBER() OVER (
PARTITION BY event_source
ORDER BY observed_at DESC, event_emitted_at DESC
) AS source_rank
FROM observations
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1)
)
SELECT
event_source,
condition_code
FROM ranked
WHERE source_rank = 1
ORDER BY event_source`
)

View File

@@ -9,6 +9,7 @@ import (
"fmt"
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*app.CurrentConditions, error) {
@@ -25,7 +26,6 @@ func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMin
&row.RelativeHumidityPercent,
&row.WindSpeedKmh,
&row.WindDirectionDegrees,
&row.ConditionCode,
&row.IsDay,
)
if errors.Is(err, sql.ErrNoRows) {
@@ -35,5 +35,42 @@ func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMin
return nil, fmt.Errorf("query current conditions: %w", err)
}
return mapCurrentConditionsRow(row), nil
if row.SampleCount == 0 {
return nil, nil
}
candidates, err := r.currentConditionsConditionCodeCandidates(ctx, observationWindowMinutes)
if err != nil {
return nil, err
}
return mapCurrentConditionsRow(row, selectCurrentConditionsConditionCode(candidates)), nil
}
func (r *Repository) currentConditionsConditionCodeCandidates(ctx context.Context, observationWindowMinutes int) ([]currentConditionsConditionCodeCandidate, error) {
rows, err := r.db.QueryContext(ctx, queryCurrentConditionsConditionCodeCandidates, observationWindowMinutes)
if err != nil {
return nil, fmt.Errorf("query current conditions condition code candidates: %w", err)
}
defer rows.Close()
var candidates []currentConditionsConditionCodeCandidate
for rows.Next() {
var (
eventSource string
conditionCode int64
)
if err := rows.Scan(&eventSource, &conditionCode); err != nil {
return nil, fmt.Errorf("scan current conditions condition code candidate: %w", err)
}
candidates = append(candidates, currentConditionsConditionCodeCandidate{
EventSource: eventSource,
ConditionCode: model.WMOCode(conditionCode),
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate current conditions condition code candidates: %w", err)
}
return candidates, nil
}

View File

@@ -0,0 +1,280 @@
// conditions_read_test.go validates current-conditions repository read flow.
// Layer: adapters/outbound/postgres conditions read tests.
package postgres
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"io"
"strings"
"sync"
"testing"
)
const currentConditionsTestDriverName = "weatherapi_current_conditions_read_test"
func init() {
sql.Register(currentConditionsTestDriverName, currentConditionsTestDriver{})
}
func TestCurrentConditionsUsesConsensusConditionCode(t *testing.T) {
repo, closeDB := openCurrentConditionsTestRepository(t,
currentConditionsAggregateQuery(currentConditionsAggregateRow(3), nil),
currentConditionsConditionCodeCandidatesQuery([][]driver.Value{
{"source-a", int64(1)},
{"source-b", int64(2)},
{"source-c", int64(95)},
}, nil),
)
defer closeDB()
conditions, err := repo.CurrentConditions(context.Background(), 15)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if conditions == nil {
t.Fatal("expected current conditions")
}
if conditions.ConditionCode != 1 {
t.Fatalf("expected consensus condition code 1, got %d", conditions.ConditionCode)
}
if conditions.TemperatureC == nil || *conditions.TemperatureC != 15.5 {
t.Fatalf("expected temperature 15.5, got %v", conditions.TemperatureC)
}
if conditions.WindDirectionDegrees == nil || *conditions.WindDirectionDegrees != 182.5 {
t.Fatalf("expected wind direction 182.5, got %v", conditions.WindDirectionDegrees)
}
if conditions.IsDay == nil || !*conditions.IsDay {
t.Fatalf("expected isDay true, got %v", conditions.IsDay)
}
assertCurrentConditionsTestQueriesConsumed(t)
}
func TestCurrentConditionsNoSamplesSkipsConditionCodeCandidates(t *testing.T) {
repo, closeDB := openCurrentConditionsTestRepository(t,
currentConditionsAggregateQuery([]driver.Value{int64(0), nil, nil, nil, nil, nil, nil, nil}, nil),
)
defer closeDB()
conditions, err := repo.CurrentConditions(context.Background(), 15)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if conditions != nil {
t.Fatalf("expected nil current conditions, got %+v", conditions)
}
assertCurrentConditionsTestQueriesConsumed(t)
}
func TestCurrentConditionsConditionCodeCandidateQueryUsesLatestPerSourceOrdering(t *testing.T) {
query := compactSQL(queryCurrentConditionsConditionCodeCandidates)
want := "PARTITION BY event_source ORDER BY observed_at DESC, event_emitted_at DESC"
if !strings.Contains(query, want) {
t.Fatalf("expected condition code candidate query to contain %q, got %q", want, query)
}
if !strings.Contains(query, "SELECT event_source, condition_code") {
t.Fatalf("expected condition code candidate query to select event_source and condition_code, got %q", query)
}
}
func TestCurrentConditionsAggregateQueryDoesNotSelectConditionCode(t *testing.T) {
query := compactSQL(queryCurrentConditions)
if strings.Contains(query, "condition_code") {
t.Fatalf("expected aggregate query not to select condition_code, got %q", query)
}
}
func TestCurrentConditionsConditionCodeCandidateQueryWrapsErrors(t *testing.T) {
repo, closeDB := openCurrentConditionsTestRepository(t,
currentConditionsAggregateQuery(currentConditionsAggregateRow(1), nil),
scriptedCurrentConditionsQuery{
name: "condition code candidates",
query: queryCurrentConditionsConditionCodeCandidates,
args: []driver.Value{int64(15)},
err: errors.New("candidate query unavailable"),
},
)
defer closeDB()
_, err := repo.CurrentConditions(context.Background(), 15)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "query current conditions condition code candidates") {
t.Fatalf("expected candidate query context, got %v", err)
}
}
func openCurrentConditionsTestRepository(t *testing.T, queries ...scriptedCurrentConditionsQuery) (*Repository, func()) {
t.Helper()
currentConditionsTestScript.set(queries)
db, err := sql.Open(currentConditionsTestDriverName, "")
if err != nil {
t.Fatalf("open test db: %v", err)
}
db.SetMaxOpenConns(1)
return NewRepository(db), func() {
_ = db.Close()
currentConditionsTestScript.set(nil)
}
}
func assertCurrentConditionsTestQueriesConsumed(t *testing.T) {
t.Helper()
if remaining := currentConditionsTestScript.remaining(); remaining != 0 {
t.Fatalf("expected all scripted queries consumed, got %d remaining", remaining)
}
}
func currentConditionsAggregateQuery(row []driver.Value, nextErr error) scriptedCurrentConditionsQuery {
return scriptedCurrentConditionsQuery{
name: "aggregate",
query: queryCurrentConditions,
args: []driver.Value{int64(15)},
columns: []string{"sample_count", "temperature_c", "apparent_temperature_c", "dewpoint_c", "relative_humidity_percent", "wind_speed_kmh", "wind_direction_degrees", "is_day"},
rows: [][]driver.Value{row},
nextErr: nextErr,
}
}
func currentConditionsConditionCodeCandidatesQuery(rows [][]driver.Value, nextErr error) scriptedCurrentConditionsQuery {
return scriptedCurrentConditionsQuery{
name: "condition code candidates",
query: queryCurrentConditionsConditionCodeCandidates,
args: []driver.Value{int64(15)},
columns: []string{"event_source", "condition_code"},
rows: rows,
nextErr: nextErr,
}
}
func currentConditionsAggregateRow(sampleCount int64) []driver.Value {
return []driver.Value{
sampleCount,
float64(15.5),
float64(14.2),
float64(10.1),
float64(72),
float64(24.8),
float64(182.5),
true,
}
}
type currentConditionsTestDriver struct{}
func (currentConditionsTestDriver) Open(string) (driver.Conn, error) {
return currentConditionsTestConn{}, nil
}
type currentConditionsTestConn struct{}
func (currentConditionsTestConn) Prepare(string) (driver.Stmt, error) {
return nil, errors.New("prepare is not supported")
}
func (currentConditionsTestConn) Close() error {
return nil
}
func (currentConditionsTestConn) Begin() (driver.Tx, error) {
return nil, errors.New("transactions are not supported")
}
func (currentConditionsTestConn) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
return currentConditionsTestScript.next(query, args)
}
type scriptedCurrentConditionsQuery struct {
name string
query string
args []driver.Value
columns []string
rows [][]driver.Value
err error
nextErr error
}
type currentConditionsTestScriptState struct {
mu sync.Mutex
queries []scriptedCurrentConditionsQuery
}
var currentConditionsTestScript currentConditionsTestScriptState
func (s *currentConditionsTestScriptState) set(queries []scriptedCurrentConditionsQuery) {
s.mu.Lock()
defer s.mu.Unlock()
s.queries = append([]scriptedCurrentConditionsQuery(nil), queries...)
}
func (s *currentConditionsTestScriptState) remaining() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.queries)
}
func (s *currentConditionsTestScriptState) next(query string, args []driver.NamedValue) (driver.Rows, error) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.queries) == 0 {
return nil, fmt.Errorf("unexpected query: %s", compactSQL(query))
}
next := s.queries[0]
s.queries = s.queries[1:]
if compactSQL(query) != compactSQL(next.query) {
return nil, fmt.Errorf("expected %s query %q, got %q", next.name, compactSQL(next.query), compactSQL(query))
}
if len(args) != len(next.args) {
return nil, fmt.Errorf("expected %s args %v, got %v", next.name, next.args, namedValues(args))
}
for i, arg := range args {
if arg.Value != next.args[i] {
return nil, fmt.Errorf("expected %s arg %d to be %v, got %v", next.name, i, next.args[i], arg.Value)
}
}
if next.err != nil {
return nil, next.err
}
return &currentConditionsTestRows{
columns: append([]string(nil), next.columns...),
rows: append([][]driver.Value(nil), next.rows...),
nextErr: next.nextErr,
}, nil
}
type currentConditionsTestRows struct {
columns []string
rows [][]driver.Value
index int
nextErr error
}
func (r *currentConditionsTestRows) Columns() []string {
return r.columns
}
func (r *currentConditionsTestRows) Close() error {
return nil
}
func (r *currentConditionsTestRows) Next(dest []driver.Value) error {
if r.index >= len(r.rows) {
if r.nextErr != nil {
err := r.nextErr
r.nextErr = nil
return err
}
return io.EOF
}
copy(dest, r.rows[r.index])
r.index++
return nil
}

View File

@@ -12,6 +12,5 @@ type currentConditionsRow struct {
RelativeHumidityPercent sql.NullFloat64
WindSpeedKmh sql.NullFloat64
WindDirectionDegrees sql.NullFloat64
ConditionCode sql.NullInt64
IsDay sql.NullBool
}

View File

@@ -41,9 +41,6 @@ func mapOutlookRow(row outlookRow) (model.WeatherOutlook, error) {
IssuedAt: row.IssuedAt.UTC(),
ExpiresAt: row.ExpiresAt.UTC(),
Forecaster: stringValue(row.Forecaster),
Headline: stringValue(row.Headline),
Summary: stringValue(row.Summary),
Discussion: stringValue(row.Discussion),
SourceURL: stringValue(row.SourceURL),
ImageURL: stringValue(row.ImageURL),
ContainsLocation: row.ContainsLocation,
@@ -51,6 +48,16 @@ func mapOutlookRow(row outlookRow) (model.WeatherOutlook, error) {
}, nil
}
func mapOutlookDiscussionRow(row outlookDiscussionRow) model.WeatherOutlookDiscussion {
return model.WeatherOutlookDiscussion{
Day: row.Day,
Headline: stringValue(row.Headline),
Summary: stringValue(row.Summary),
Discussion: stringValue(row.Discussion),
UpdatedAt: timePtr(row.UpdatedAt),
}
}
func intPtr(v sql.NullInt64) *int {
if !v.Valid {
return nil

View File

@@ -79,9 +79,6 @@ func TestMapOutlookRowMapsFields(t *testing.T) {
IssuedAt: issuedAt,
ExpiresAt: expiresAt,
Forecaster: sql.NullString{String: "DIAL", Valid: true},
Headline: sql.NullString{String: "Severe storms possible", Valid: true},
Summary: sql.NullString{String: "Scattered severe storms are possible.", Valid: true},
Discussion: sql.NullString{String: "Discussion text.", Valid: true},
SourceURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.html", Valid: true},
ImageURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.gif", Valid: true},
ContainsLocation: true,
@@ -103,8 +100,8 @@ func TestMapOutlookRowMapsFields(t *testing.T) {
if outlook.SeverityRank == nil || *outlook.SeverityRank != int(severityRank) {
t.Fatalf("expected severity rank %d, got %v", severityRank, outlook.SeverityRank)
}
if outlook.Forecaster != "DIAL" || outlook.Headline == "" || outlook.Summary == "" || outlook.Discussion == "" {
t.Fatalf("unexpected text fields: %+v", outlook)
if outlook.Forecaster != "DIAL" {
t.Fatalf("unexpected forecaster: %+v", outlook)
}
if outlook.SourceURL == "" || outlook.ImageURL == "" {
t.Fatalf("expected source and image URLs: %+v", outlook)
@@ -142,8 +139,7 @@ func TestMapOutlookRowMissingOptionals(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
if outlook.LabelText != "" || outlook.Forecaster != "" || outlook.Headline != "" ||
outlook.Summary != "" || outlook.Discussion != "" || outlook.SourceURL != "" || outlook.ImageURL != "" {
if outlook.LabelText != "" || outlook.Forecaster != "" || outlook.SourceURL != "" || outlook.ImageURL != "" {
t.Fatalf("expected optional strings to map to empty values, got %+v", outlook)
}
if outlook.SeverityRank != nil {
@@ -172,3 +168,49 @@ func TestMapOutlookRowRejectsInvalidGeometry(t *testing.T) {
t.Fatal("expected invalid geometry error")
}
}
func TestMapOutlookDiscussionRowMapsFields(t *testing.T) {
updatedAt := time.Date(2026, 6, 11, 7, 30, 0, 0, time.FixedZone("CDT", -5*3600))
discussion := mapOutlookDiscussionRow(outlookDiscussionRow{
DiscussionIndex: 2,
Day: 2,
Headline: sql.NullString{String: "Severe storms possible", Valid: true},
Summary: sql.NullString{String: "Scattered severe storms are possible.", Valid: true},
Discussion: sql.NullString{String: "Discussion text.", Valid: true},
UpdatedAt: sql.NullTime{Time: updatedAt, Valid: true},
})
if discussion.Day != 2 {
t.Fatalf("expected day 2, got %d", discussion.Day)
}
if discussion.Headline != "Severe storms possible" {
t.Fatalf("unexpected headline: %q", discussion.Headline)
}
if discussion.Summary != "Scattered severe storms are possible." {
t.Fatalf("unexpected summary: %q", discussion.Summary)
}
if discussion.Discussion != "Discussion text." {
t.Fatalf("unexpected discussion: %q", discussion.Discussion)
}
if discussion.UpdatedAt == nil || discussion.UpdatedAt.Location().String() != "UTC" {
t.Fatalf("expected updatedAt UTC pointer, got %v", discussion.UpdatedAt)
}
}
func TestMapOutlookDiscussionRowMissingOptionals(t *testing.T) {
discussion := mapOutlookDiscussionRow(outlookDiscussionRow{
DiscussionIndex: 1,
Day: 1,
})
if discussion.Day != 1 {
t.Fatalf("expected day 1, got %d", discussion.Day)
}
if discussion.Headline != "" || discussion.Summary != "" || discussion.Discussion != "" {
t.Fatalf("expected empty optional strings, got %+v", discussion)
}
if discussion.UpdatedAt != nil {
t.Fatalf("expected nil updatedAt, got %v", discussion.UpdatedAt)
}
}

View File

@@ -32,9 +32,6 @@ SELECT
issued_at,
expires_at,
forecaster,
headline,
summary,
discussion,
source_url,
image_url,
contains_location,
@@ -42,4 +39,16 @@ SELECT
FROM outlooks
WHERE run_event_id = $1
ORDER BY outlook_index ASC`
queryOutlookDiscussionsForRun = `
SELECT
discussion_index,
day,
headline,
summary,
discussion,
updated_at
FROM outlook_discussions
WHERE run_event_id = $1
ORDER BY discussion_index ASC`
)

View File

@@ -40,6 +40,12 @@ func (r *Repository) LatestConvectiveOutlookRun(ctx context.Context) (*model.Wea
}
run.Outlooks = outlooks
discussions, err := r.loadOutlookDiscussions(ctx, row.EventID)
if err != nil {
return nil, err
}
run.Discussions = discussions
return &run, nil
}
@@ -68,9 +74,6 @@ func (r *Repository) loadOutlooks(ctx context.Context, eventID string) ([]model.
&row.IssuedAt,
&row.ExpiresAt,
&row.Forecaster,
&row.Headline,
&row.Summary,
&row.Discussion,
&row.SourceURL,
&row.ImageURL,
&row.ContainsLocation,
@@ -90,3 +93,32 @@ func (r *Repository) loadOutlooks(ctx context.Context, eventID string) ([]model.
}
return out, nil
}
func (r *Repository) loadOutlookDiscussions(ctx context.Context, eventID string) ([]model.WeatherOutlookDiscussion, error) {
rows, err := r.db.QueryContext(ctx, queryOutlookDiscussionsForRun, eventID)
if err != nil {
return nil, fmt.Errorf("query outlook discussions: %w", err)
}
defer rows.Close()
out := make([]model.WeatherOutlookDiscussion, 0)
for rows.Next() {
var row outlookDiscussionRow
if err := rows.Scan(
&row.DiscussionIndex,
&row.Day,
&row.Headline,
&row.Summary,
&row.Discussion,
&row.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan outlook discussion row: %w", err)
}
out = append(out, mapOutlookDiscussionRow(row))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate outlook discussion rows: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,405 @@
// outlooks_read_test.go validates outlook repository read flow.
// Layer: adapters/outbound/postgres outlook read tests.
package postgres
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"io"
"strings"
"sync"
"testing"
"time"
)
const outlookTestDriverName = "weatherapi_outlook_read_test"
func init() {
sql.Register(outlookTestDriverName, outlookTestDriver{})
}
func TestLatestConvectiveOutlookRunLoadsOutlooksAndDiscussions(t *testing.T) {
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
issuedAt := asOf.Add(-1 * time.Hour)
discussionUpdated := asOf.Add(-30 * time.Minute)
repo, closeDB := openOutlookTestRepository(t,
outlookParentQuery([][]driver.Value{{
"evt-outlook-run",
"stl",
"St. Louis",
float64(38.62),
float64(-90.2),
asOf,
issuedAt,
}}),
outlookRowsQuery([][]driver.Value{
outlookReadRow(1, "day-1", 1, "categorical"),
outlookReadRow(2, "day-2", 2, "wind"),
}, nil),
outlookDiscussionsQuery([][]driver.Value{
{int64(1), int64(1), "Day 1 headline", "Day 1 summary", "Day 1 discussion", discussionUpdated},
{int64(2), int64(2), "Day 2 headline", nil, "Day 2 discussion", nil},
}, nil),
)
defer closeDB()
run, err := repo.LatestConvectiveOutlookRun(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run == nil {
t.Fatal("expected outlook run")
}
if run.LocationID != "stl" || run.LocationName != "St. Louis" {
t.Fatalf("unexpected run metadata: %+v", run)
}
if len(run.Outlooks) != 2 {
t.Fatalf("expected 2 outlooks, got %d", len(run.Outlooks))
}
if run.Outlooks[0].ID != "day-1" || run.Outlooks[1].ID != "day-2" {
t.Fatalf("expected outlook order from rows, got %+v", run.Outlooks)
}
if len(run.Discussions) != 2 {
t.Fatalf("expected 2 discussions, got %d", len(run.Discussions))
}
if run.Discussions[0].Day != 1 || run.Discussions[0].Headline != "Day 1 headline" {
t.Fatalf("unexpected first discussion: %+v", run.Discussions[0])
}
if run.Discussions[1].Day != 2 || run.Discussions[1].Summary != "" {
t.Fatalf("unexpected second discussion: %+v", run.Discussions[1])
}
if run.Discussions[0].UpdatedAt == nil || run.Discussions[0].UpdatedAt.Location().String() != "UTC" {
t.Fatalf("expected discussion updatedAt UTC pointer, got %v", run.Discussions[0].UpdatedAt)
}
if run.Discussions[1].UpdatedAt != nil {
t.Fatalf("expected nil discussion updatedAt, got %v", run.Discussions[1].UpdatedAt)
}
assertOutlookTestQueriesConsumed(t)
}
func TestLatestConvectiveOutlookRunMissingParentReturnsNil(t *testing.T) {
repo, closeDB := openOutlookTestRepository(t, outlookParentQuery(nil))
defer closeDB()
run, err := repo.LatestConvectiveOutlookRun(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run != nil {
t.Fatalf("expected nil run, got %+v", run)
}
assertOutlookTestQueriesConsumed(t)
}
func TestLatestConvectiveOutlookRunWrapsReadErrors(t *testing.T) {
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
tests := []struct {
name string
queries []scriptedOutlookQuery
want string
}{
{
name: "parent query",
queries: []scriptedOutlookQuery{
{name: "parent", query: queryLatestConvectiveOutlookRun, err: errors.New("parent unavailable")},
},
want: "query latest convective outlook run",
},
{
name: "outlooks query",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
{name: "outlooks", query: queryOutlooksForRun, args: []driver.Value{"evt-outlook-run"}, err: errors.New("outlooks unavailable")},
},
want: "query outlooks",
},
{
name: "outlook scan",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
outlookRowsQuery([][]driver.Value{
{int64(1), "day-1"},
}, nil),
},
want: "scan outlook row",
},
{
name: "outlook map",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
outlookRowsQuery([][]driver.Value{
outlookReadRowWithGeometry(1, "day-1", 1, "categorical", `{"type":"Point"`),
}, nil),
},
want: "map outlook row",
},
{
name: "outlook iteration",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
outlookRowsQuery([][]driver.Value{
outlookReadRow(1, "day-1", 1, "categorical"),
}, errors.New("outlook iteration failed")),
},
want: "iterate outlook rows",
},
{
name: "discussions query",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
outlookRowsQuery(nil, nil),
{name: "discussions", query: queryOutlookDiscussionsForRun, args: []driver.Value{"evt-outlook-run"}, err: errors.New("discussions unavailable")},
},
want: "query outlook discussions",
},
{
name: "discussion scan",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
outlookRowsQuery(nil, nil),
outlookDiscussionsQuery([][]driver.Value{
{int64(1), "bad day", nil, nil, nil, nil},
}, nil),
},
want: "scan outlook discussion row",
},
{
name: "discussion iteration",
queries: []scriptedOutlookQuery{
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
outlookRowsQuery(nil, nil),
outlookDiscussionsQuery([][]driver.Value{
{int64(1), int64(1), nil, nil, nil, nil},
}, errors.New("discussion iteration failed")),
},
want: "iterate outlook discussion rows",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo, closeDB := openOutlookTestRepository(t, tt.queries...)
defer closeDB()
_, err := repo.LatestConvectiveOutlookRun(context.Background())
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), tt.want) {
t.Fatalf("expected error containing %q, got %v", tt.want, err)
}
})
}
}
func openOutlookTestRepository(t *testing.T, queries ...scriptedOutlookQuery) (*Repository, func()) {
t.Helper()
outlookTestScript.set(queries)
db, err := sql.Open(outlookTestDriverName, "")
if err != nil {
t.Fatalf("open test db: %v", err)
}
db.SetMaxOpenConns(1)
return NewRepository(db), func() {
_ = db.Close()
outlookTestScript.set(nil)
}
}
func assertOutlookTestQueriesConsumed(t *testing.T) {
t.Helper()
if remaining := outlookTestScript.remaining(); remaining != 0 {
t.Fatalf("expected all scripted queries consumed, got %d remaining", remaining)
}
}
func outlookParentQuery(rows [][]driver.Value) scriptedOutlookQuery {
return scriptedOutlookQuery{
name: "parent",
query: queryLatestConvectiveOutlookRun,
columns: []string{"event_id", "location_id", "location_name", "latitude", "longitude", "as_of", "issued_at"},
rows: rows,
}
}
func outlookRowsQuery(rows [][]driver.Value, nextErr error) scriptedOutlookQuery {
return scriptedOutlookQuery{
name: "outlooks",
query: queryOutlooksForRun,
args: []driver.Value{"evt-outlook-run"},
columns: []string{"outlook_index", "outlook_id", "provider", "product", "day", "outlook_type", "label", "label_text", "severity_rank", "valid_from", "valid_to", "issued_at", "expires_at", "forecaster", "source_url", "image_url", "contains_location", "geometry_json"},
rows: rows,
nextErr: nextErr,
}
}
func outlookDiscussionsQuery(rows [][]driver.Value, nextErr error) scriptedOutlookQuery {
return scriptedOutlookQuery{
name: "discussions",
query: queryOutlookDiscussionsForRun,
args: []driver.Value{"evt-outlook-run"},
columns: []string{"discussion_index", "day", "headline", "summary", "discussion", "updated_at"},
rows: rows,
nextErr: nextErr,
}
}
func outlookReadRow(index int64, id string, day int64, outlookType string) []driver.Value {
return outlookReadRowWithGeometry(index, id, day, outlookType, `{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,38]]]}`)
}
func outlookReadRowWithGeometry(index int64, id string, day int64, outlookType string, geometry string) []driver.Value {
validFrom := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
validTo := validFrom.Add(6 * time.Hour)
return []driver.Value{
index,
id,
"spc",
"convective",
day,
outlookType,
"SLGT",
"Slight Risk",
int64(5),
validFrom,
validTo,
validFrom.Add(-1 * time.Hour),
validTo,
"DIAL",
"https://example.test/source",
"https://example.test/image.png",
true,
geometry,
}
}
type outlookTestDriver struct{}
func (outlookTestDriver) Open(string) (driver.Conn, error) {
return outlookTestConn{}, nil
}
type outlookTestConn struct{}
func (outlookTestConn) Prepare(string) (driver.Stmt, error) {
return nil, errors.New("prepare is not supported")
}
func (outlookTestConn) Close() error {
return nil
}
func (outlookTestConn) Begin() (driver.Tx, error) {
return nil, errors.New("transactions are not supported")
}
func (outlookTestConn) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
return outlookTestScript.next(query, args)
}
type scriptedOutlookQuery struct {
name string
query string
args []driver.Value
columns []string
rows [][]driver.Value
err error
nextErr error
}
type outlookTestScriptState struct {
mu sync.Mutex
queries []scriptedOutlookQuery
}
var outlookTestScript outlookTestScriptState
func (s *outlookTestScriptState) set(queries []scriptedOutlookQuery) {
s.mu.Lock()
defer s.mu.Unlock()
s.queries = append([]scriptedOutlookQuery(nil), queries...)
}
func (s *outlookTestScriptState) remaining() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.queries)
}
func (s *outlookTestScriptState) next(query string, args []driver.NamedValue) (driver.Rows, error) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.queries) == 0 {
return nil, fmt.Errorf("unexpected query: %s", compactSQL(query))
}
next := s.queries[0]
s.queries = s.queries[1:]
if compactSQL(query) != compactSQL(next.query) {
return nil, fmt.Errorf("expected %s query %q, got %q", next.name, compactSQL(next.query), compactSQL(query))
}
if len(args) != len(next.args) {
return nil, fmt.Errorf("expected %s args %v, got %v", next.name, next.args, namedValues(args))
}
for i, arg := range args {
if arg.Value != next.args[i] {
return nil, fmt.Errorf("expected %s arg %d to be %v, got %v", next.name, i, next.args[i], arg.Value)
}
}
if next.err != nil {
return nil, next.err
}
return &outlookTestRows{
columns: append([]string(nil), next.columns...),
rows: append([][]driver.Value(nil), next.rows...),
nextErr: next.nextErr,
}, nil
}
type outlookTestRows struct {
columns []string
rows [][]driver.Value
index int
nextErr error
}
func (r *outlookTestRows) Columns() []string {
return r.columns
}
func (r *outlookTestRows) Close() error {
return nil
}
func (r *outlookTestRows) Next(dest []driver.Value) error {
if r.index >= len(r.rows) {
if r.nextErr != nil {
err := r.nextErr
r.nextErr = nil
return err
}
return io.EOF
}
copy(dest, r.rows[r.index])
r.index++
return nil
}
func compactSQL(query string) string {
return strings.Join(strings.Fields(query), " ")
}
func namedValues(args []driver.NamedValue) []driver.Value {
out := make([]driver.Value, len(args))
for i := range args {
out[i] = args[i].Value
}
return out
}

View File

@@ -32,11 +32,17 @@ type outlookRow struct {
IssuedAt time.Time
ExpiresAt time.Time
Forecaster sql.NullString
Headline sql.NullString
Summary sql.NullString
Discussion sql.NullString
SourceURL sql.NullString
ImageURL sql.NullString
ContainsLocation bool
GeometryJSON string
}
type outlookDiscussionRow struct {
DiscussionIndex int
Day int
Headline sql.NullString
Summary sql.NullString
Discussion sql.NullString
UpdatedAt sql.NullTime
}

View File

@@ -246,10 +246,40 @@ func TestAttachAlertReferencesPreservesOrder(t *testing.T) {
}
}
func TestMapAlertRowMapsEndsAndExpires(t *testing.T) {
ends := time.Date(2026, 6, 16, 14, 0, 0, 0, time.FixedZone("CDT", -5*3600))
expires := time.Date(2026, 6, 16, 11, 0, 0, 0, time.FixedZone("CDT", -5*3600))
alert := mapAlertRow(alertRow{
AlertIndex: 1,
AlertID: "alert-1",
Ends: sql.NullTime{Time: ends, Valid: true},
Expires: sql.NullTime{Time: expires, Valid: true},
}).Alert
if alert.Ends == nil || alert.Ends.Location().String() != "UTC" || !alert.Ends.Equal(ends.UTC()) {
t.Fatalf("expected ends UTC %s, got %v", ends.UTC(), alert.Ends)
}
if alert.Expires == nil || alert.Expires.Location().String() != "UTC" || !alert.Expires.Equal(expires.UTC()) {
t.Fatalf("expected expires UTC %s, got %v", expires.UTC(), alert.Expires)
}
}
func TestMapAlertRowNullableEnds(t *testing.T) {
alert := mapAlertRow(alertRow{
AlertIndex: 1,
AlertID: "alert-1",
}).Alert
if alert.Ends != nil {
t.Fatalf("expected nil ends, got %v", alert.Ends)
}
}
func TestMapCurrentConditionsRowNoSamplesReturnsNil(t *testing.T) {
got := mapCurrentConditionsRow(currentConditionsRow{
SampleCount: 0,
})
}, model.WMOUnknown)
if got != nil {
t.Fatalf("expected nil for empty sample window, got %+v", got)
}
@@ -265,9 +295,8 @@ func TestMapCurrentConditionsRowMapsFields(t *testing.T) {
RelativeHumidityPercent: sql.NullFloat64{Float64: 72, Valid: true},
WindSpeedKmh: sql.NullFloat64{Float64: 24.8, Valid: true},
WindDirectionDegrees: sql.NullFloat64{Float64: 182.5, Valid: true},
ConditionCode: sql.NullInt64{Int64: 65, Valid: true},
IsDay: sql.NullBool{Bool: isDay, Valid: true},
})
}, 65)
if got == nil {
t.Fatalf("expected mapped current conditions")
}

View File

@@ -10,6 +10,8 @@ import (
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
const nwsAlertURLPrefix = "https://api.weather.gov/alerts/"
// Repository defines outbound data access used by weatherapi use cases.
type Repository interface {
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
@@ -25,10 +27,9 @@ type Repository interface {
// OutlookFilter selects outlook entries from the latest convective outlook run.
type OutlookFilter struct {
Day *int
OutlookType string
ContainsLocation *bool
ActiveAt *time.Time
Day *int
OutlookType string
ActiveAt *time.Time
}
// Service provides weather read use-cases.
@@ -68,6 +69,27 @@ func (s *Service) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, e
return s.repo.LatestAlertRun(ctx)
}
func (s *Service) LatestActiveAlertRun(ctx context.Context, activeAt time.Time) (*model.WeatherAlertRun, error) {
run, err := s.repo.LatestAlertRun(ctx)
if err != nil {
return nil, err
}
if run == nil {
return nil, nil
}
out := cloneAlertRun(run)
supersededIDs := collectSupersededAlertIDs(out.Alerts)
alerts := out.Alerts[:0]
for _, alert := range out.Alerts {
if isActiveAlert(alert, activeAt) && !isSupersededAlert(alert, supersededIDs) {
alerts = append(alerts, alert)
}
}
out.Alerts = alerts
return out, nil
}
func (s *Service) LatestConvectiveOutlook(ctx context.Context, filter OutlookFilter) (*model.WeatherOutlookRun, error) {
run, err := s.repo.LatestConvectiveOutlookRun(ctx)
if err != nil {
@@ -85,6 +107,7 @@ func (s *Service) LatestConvectiveOutlook(ctx context.Context, filter OutlookFil
}
}
out.Outlooks = outlooks
out.Discussions = filterOutlookDiscussions(out.Discussions, out.Outlooks)
return out, nil
}
@@ -99,9 +122,6 @@ func matchesOutlookFilter(outlook model.WeatherOutlook, filter OutlookFilter) bo
if filter.OutlookType != "" && outlook.OutlookType != normalizeOutlookType(filter.OutlookType) {
return false
}
if filter.ContainsLocation != nil && outlook.ContainsLocation != *filter.ContainsLocation {
return false
}
if filter.ActiveAt != nil && (filter.ActiveAt.Before(outlook.ValidFrom) || !filter.ActiveAt.Before(outlook.ValidTo)) {
return false
}
@@ -112,6 +132,93 @@ func normalizeOutlookType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func cloneAlertRun(run *model.WeatherAlertRun) *model.WeatherAlertRun {
out := *run
out.Latitude = copyFloat64(run.Latitude)
out.Longitude = copyFloat64(run.Longitude)
if run.Alerts != nil {
out.Alerts = make([]model.WeatherAlert, len(run.Alerts))
for i := range run.Alerts {
out.Alerts[i] = cloneAlert(run.Alerts[i])
}
}
return &out
}
func cloneAlert(alert model.WeatherAlert) model.WeatherAlert {
out := alert
out.Sent = copyTime(alert.Sent)
out.Effective = copyTime(alert.Effective)
out.Onset = copyTime(alert.Onset)
out.Ends = copyTime(alert.Ends)
out.Expires = copyTime(alert.Expires)
if alert.References != nil {
out.References = make([]model.AlertReference, len(alert.References))
for i := range alert.References {
out.References[i] = cloneAlertReference(alert.References[i])
}
}
return out
}
func cloneAlertReference(ref model.AlertReference) model.AlertReference {
out := ref
out.Sent = copyTime(ref.Sent)
return out
}
func isActiveAlert(alert model.WeatherAlert, activeAt time.Time) bool {
if strings.EqualFold(strings.TrimSpace(alert.MessageType), "Cancel") {
return false
}
if alert.Effective != nil && activeAt.Before(*alert.Effective) {
return false
}
endBoundary := alert.Ends
if endBoundary == nil {
endBoundary = alert.Expires
}
if endBoundary != nil && !activeAt.Before(*endBoundary) {
return false
}
return true
}
func collectSupersededAlertIDs(alerts []model.WeatherAlert) map[string]struct{} {
supersededIDs := make(map[string]struct{})
for _, alert := range alerts {
for _, ref := range alert.References {
id := normalizeAlertID(referenceAlertID(ref))
if id != "" {
supersededIDs[id] = struct{}{}
}
}
}
return supersededIDs
}
func isSupersededAlert(alert model.WeatherAlert, supersededIDs map[string]struct{}) bool {
id := normalizeAlertID(alert.ID)
if id == "" {
return false
}
_, ok := supersededIDs[id]
return ok
}
func referenceAlertID(ref model.AlertReference) string {
if strings.TrimSpace(ref.Identifier) != "" {
return ref.Identifier
}
return ref.ID
}
func normalizeAlertID(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, nwsAlertURLPrefix)
return value
}
func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
out := *run
out.Latitude = copyFloat64(run.Latitude)
@@ -123,6 +230,12 @@ func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
out.Outlooks[i] = cloneOutlook(run.Outlooks[i])
}
}
if run.Discussions != nil {
out.Discussions = make([]model.WeatherOutlookDiscussion, len(run.Discussions))
for i := range run.Discussions {
out.Discussions[i] = cloneOutlookDiscussion(run.Discussions[i])
}
}
return &out
}
@@ -135,6 +248,31 @@ func cloneOutlook(outlook model.WeatherOutlook) model.WeatherOutlook {
return out
}
func cloneOutlookDiscussion(discussion model.WeatherOutlookDiscussion) model.WeatherOutlookDiscussion {
out := discussion
out.UpdatedAt = copyTime(discussion.UpdatedAt)
return out
}
func filterOutlookDiscussions(discussions []model.WeatherOutlookDiscussion, outlooks []model.WeatherOutlook) []model.WeatherOutlookDiscussion {
if len(outlooks) == 0 {
return []model.WeatherOutlookDiscussion{}
}
retainedDays := make(map[int]struct{}, len(outlooks))
for _, outlook := range outlooks {
retainedDays[outlook.Day] = struct{}{}
}
out := discussions[:0]
for _, discussion := range discussions {
if _, ok := retainedDays[discussion.Day]; ok {
out = append(out, discussion)
}
}
return out
}
func copyFloat64(value *float64) *float64 {
if value == nil {
return nil

View File

@@ -5,6 +5,7 @@ package app
import (
"context"
"errors"
"strconv"
"testing"
"time"
@@ -24,6 +25,7 @@ type fakeRepository struct {
err error
currentConditionsWindow int
alertRunCalls int
outlookRunCalls int
}
@@ -52,6 +54,7 @@ func (r *fakeRepository) LatestWeatherStory(context.Context) (*model.WeatherStor
}
func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
r.alertRunCalls++
return r.alerts, r.err
}
@@ -117,6 +120,212 @@ func TestServiceDelegatesAlerts(t *testing.T) {
}
}
func TestServiceLatestActiveAlertRunDelegatesAndFilters(t *testing.T) {
activeAt := testTime(12)
repo := &fakeRepository{alerts: testAlertRun()}
svc := NewService(repo)
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if repo.alertRunCalls != 1 {
t.Fatalf("expected one repository call, got %d", repo.alertRunCalls)
}
assertAlertIDs(t, run, []string{"current", "effective-at-boundary", "missing-effective", "missing-expires", "later-onset", "ends-preferred"})
}
func TestServiceLatestActiveAlertRunNoData(t *testing.T) {
repo := &fakeRepository{}
svc := NewService(repo)
run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run != nil {
t.Fatalf("expected nil alert run, got %+v", run)
}
}
func TestServiceLatestActiveAlertRunPropagatesErrors(t *testing.T) {
want := errors.New("alert read failed")
repo := &fakeRepository{err: want}
svc := NewService(repo)
run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12))
if !errors.Is(err, want) {
t.Fatalf("expected error %v, got %v", want, err)
}
if run != nil {
t.Fatalf("expected nil alert run on error, got %+v", run)
}
}
func TestServiceLatestActiveAlertRunKeepsMetadataWithEmptyAlerts(t *testing.T) {
activeAt := testTime(12)
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{
testAlert("expired", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12), testTimePtr(13)),
testAlert("cancel", "Cancel", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13), testTimePtr(13)),
testAlert("future", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(14), testTimePtr(14)),
})}
svc := NewService(repo)
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if run == nil {
t.Fatal("expected alert run")
}
if run.LocationID != "stl" || run.LocationName != "St. Louis" || !run.AsOf.Equal(testTime(10)) {
t.Fatalf("unexpected run metadata: %+v", run)
}
if run.Latitude == nil || *run.Latitude != 38.62 {
t.Fatalf("unexpected latitude: %v", run.Latitude)
}
if run.Longitude == nil || *run.Longitude != -90.2 {
t.Fatalf("unexpected longitude: %v", run.Longitude)
}
if run.Alerts == nil {
t.Fatal("expected empty alert slice, got nil")
}
if len(run.Alerts) != 0 {
t.Fatalf("expected no alerts, got %+v", run.Alerts)
}
}
func TestServiceLatestActiveAlertRunDoesNotMutateRepositoryRun(t *testing.T) {
original := testAlertRun()
repo := &fakeRepository{alerts: original}
svc := NewService(repo)
run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(run.Alerts) == 0 {
t.Fatal("expected active alerts")
}
*run.Latitude = 99
*run.Longitude = -99
*run.Alerts[0].Sent = testTime(1)
*run.Alerts[0].Effective = testTime(2)
*run.Alerts[0].Onset = testTime(3)
*run.Alerts[0].Ends = testTime(4)
*run.Alerts[0].Expires = testTime(5)
*run.Alerts[0].References[0].Sent = testTime(5)
run.Alerts[0].ID = "changed"
run.Alerts[0].References[0].ID = "changed"
run.Alerts = run.Alerts[:1]
if *original.Latitude != 38.62 {
t.Fatalf("expected original latitude unchanged, got %v", *original.Latitude)
}
if *original.Longitude != -90.2 {
t.Fatalf("expected original longitude unchanged, got %v", *original.Longitude)
}
if original.Alerts[0].ID != "current" {
t.Fatalf("expected original alert ID unchanged, got %q", original.Alerts[0].ID)
}
if original.Alerts[0].Sent == nil || !original.Alerts[0].Sent.Equal(testTime(9)) {
t.Fatalf("expected original sent unchanged, got %v", original.Alerts[0].Sent)
}
if original.Alerts[0].Effective == nil || !original.Alerts[0].Effective.Equal(testTime(10)) {
t.Fatalf("expected original effective unchanged, got %v", original.Alerts[0].Effective)
}
if original.Alerts[0].Onset == nil || !original.Alerts[0].Onset.Equal(testTime(11)) {
t.Fatalf("expected original onset unchanged, got %v", original.Alerts[0].Onset)
}
if original.Alerts[0].Ends == nil || !original.Alerts[0].Ends.Equal(testTime(13)) {
t.Fatalf("expected original ends unchanged, got %v", original.Alerts[0].Ends)
}
if original.Alerts[0].Expires == nil || !original.Alerts[0].Expires.Equal(testTime(12)) {
t.Fatalf("expected original expires unchanged, got %v", original.Alerts[0].Expires)
}
if original.Alerts[0].References[0].ID != "ref-current" {
t.Fatalf("expected original reference ID unchanged, got %q", original.Alerts[0].References[0].ID)
}
if original.Alerts[0].References[0].Sent == nil || !original.Alerts[0].References[0].Sent.Equal(testTime(8)) {
t.Fatalf("expected original reference sent unchanged, got %v", original.Alerts[0].References[0].Sent)
}
if len(original.Alerts) != 10 {
t.Fatalf("expected original alert slice unchanged, got %d entries", len(original.Alerts))
}
}
func TestServiceLatestActiveAlertRunUsesEndsBeforeExpires(t *testing.T) {
activeAt := testTime(12)
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{
testAlert("ends-at-boundary", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12), testTimePtr(13)),
testAlert("ends-after-active-expires-before", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13), testTimePtr(11)),
testAlert("expires-fallback", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), nil, testTimePtr(13)),
testAlert("expires-fallback-expired", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), nil, testTimePtr(12)),
})}
svc := NewService(repo)
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertAlertIDs(t, run, []string{"ends-after-active-expires-before", "expires-fallback"})
}
func TestServiceLatestActiveAlertRunSuppressesReferencedOriginal(t *testing.T) {
activeAt := testTime(12)
original := testAlert("https://api.weather.gov/alerts/urn:oid:original", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
update := testAlert("https://api.weather.gov/alerts/urn:oid:update", "Update", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13))
update.References = []model.AlertReference{{Identifier: "urn:oid:original"}}
unrelated := testAlert("https://api.weather.gov/alerts/urn:oid:unrelated", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{original, update, unrelated})}
svc := NewService(repo)
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertAlertIDs(t, run, []string{
"https://api.weather.gov/alerts/urn:oid:update",
"https://api.weather.gov/alerts/urn:oid:unrelated",
})
}
func TestServiceLatestActiveAlertRunCancelSuppressesReferencedOriginal(t *testing.T) {
activeAt := testTime(12)
original := testAlert("https://api.weather.gov/alerts/urn:oid:original", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
cancel := testAlert("https://api.weather.gov/alerts/urn:oid:cancel", "Cancel", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13))
cancel.References = []model.AlertReference{{Identifier: "urn:oid:original"}}
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{original, cancel})}
svc := NewService(repo)
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertAlertIDs(t, run, []string{})
}
func TestServiceLatestActiveAlertRunReferenceIdentifierPrecedenceAndIDFallback(t *testing.T) {
activeAt := testTime(12)
fromID := testAlert("urn:oid:from-id", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
fromIdentifier := testAlert("urn:oid:from-identifier", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
idFallback := testAlert("urn:oid:id-fallback", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
update := testAlert("urn:oid:update", "Update", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13))
update.References = []model.AlertReference{
{ID: "urn:oid:from-id", Identifier: "urn:oid:from-identifier"},
{ID: "urn:oid:id-fallback"},
}
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{fromID, fromIdentifier, idFallback, update})}
svc := NewService(repo)
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertAlertIDs(t, run, []string{"urn:oid:from-id", "urn:oid:update"})
}
func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
repo := &fakeRepository{outlookRun: testOutlookRun()}
svc := NewService(repo)
@@ -131,6 +340,7 @@ func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
if run == nil || run.LocationID != "stl" {
t.Fatalf("unexpected outlook run: %+v", run)
}
assertDiscussionDays(t, run, []int{1, 2})
}
func TestServiceLatestConvectiveOutlookNoData(t *testing.T) {
@@ -208,6 +418,7 @@ func TestServiceLatestConvectiveOutlookFiltersByDay(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
assertOutlookIDs(t, run, []string{"day-2"})
assertDiscussionDays(t, run, []int{2})
}
func TestServiceLatestConvectiveOutlookFiltersByOutlookType(t *testing.T) {
@@ -219,18 +430,7 @@ func TestServiceLatestConvectiveOutlookFiltersByOutlookType(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
assertOutlookIDs(t, run, []string{"tor-1"})
}
func TestServiceLatestConvectiveOutlookFiltersByContainsLocation(t *testing.T) {
containsLocation := true
repo := &fakeRepository{outlookRun: testOutlookRun()}
svc := NewService(repo)
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ContainsLocation: &containsLocation})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertOutlookIDs(t, run, []string{"cat-1", "tor-1"})
assertDiscussionDays(t, run, []int{1})
}
func TestServiceLatestConvectiveOutlookFiltersByActiveAt(t *testing.T) {
@@ -243,25 +443,25 @@ func TestServiceLatestConvectiveOutlookFiltersByActiveAt(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
assertOutlookIDs(t, run, []string{"cat-1", "tor-1"})
assertDiscussionDays(t, run, []int{1})
}
func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) {
day := 1
containsLocation := true
activeAt := time.Date(2026, 6, 11, 15, 0, 0, 0, time.UTC)
repo := &fakeRepository{outlookRun: testOutlookRun()}
svc := NewService(repo)
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{
Day: &day,
OutlookType: "categorical",
ContainsLocation: &containsLocation,
ActiveAt: &activeAt,
Day: &day,
OutlookType: "categorical",
ActiveAt: &activeAt,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertOutlookIDs(t, run, []string{"cat-1"})
assertDiscussionDays(t, run, []int{1})
}
func TestServiceLatestConvectiveOutlookActiveAtBoundary(t *testing.T) {
@@ -277,12 +477,14 @@ func TestServiceLatestConvectiveOutlookActiveAtBoundary(t *testing.T) {
t.Fatalf("unexpected validFrom error: %v", err)
}
assertOutlookIDs(t, fromRun, []string{"cat-1"})
assertDiscussionDays(t, fromRun, []int{1})
toRun, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ActiveAt: &validTo})
if err != nil {
t.Fatalf("unexpected validTo error: %v", err)
}
assertOutlookIDs(t, toRun, nil)
assertDiscussionDays(t, toRun, nil)
}
func TestServiceLatestConvectiveOutlookKeepsRunMetadataWithEmptyOutlooks(t *testing.T) {
@@ -306,6 +508,12 @@ func TestServiceLatestConvectiveOutlookKeepsRunMetadataWithEmptyOutlooks(t *test
if len(run.Outlooks) != 0 {
t.Fatalf("expected no outlooks, got %+v", run.Outlooks)
}
if run.Discussions == nil {
t.Fatal("expected empty discussions slice, got nil")
}
if len(run.Discussions) != 0 {
t.Fatalf("expected no discussions, got %+v", run.Discussions)
}
}
func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T) {
@@ -325,9 +533,12 @@ func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T)
*run.Longitude = -99
*run.IssuedAt = testTime(99)
*run.Outlooks[0].SeverityRank = 99
*run.Discussions[0].UpdatedAt = testTime(98)
run.Outlooks[0].Geometry[0] = '{'
run.Outlooks[0].ID = "changed"
run.Discussions[0].Headline = "changed"
run.Outlooks = run.Outlooks[:1]
run.Discussions = run.Discussions[:1]
if *original.Latitude != 38.62 {
t.Fatalf("expected original latitude unchanged, got %v", *original.Latitude)
@@ -350,6 +561,15 @@ func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T)
if len(original.Outlooks) != 3 {
t.Fatalf("expected original outlook slice unchanged, got %d entries", len(original.Outlooks))
}
if original.Discussions[0].UpdatedAt == nil || !original.Discussions[0].UpdatedAt.Equal(testTime(10)) {
t.Fatalf("expected original discussion updatedAt unchanged, got %v", original.Discussions[0].UpdatedAt)
}
if original.Discussions[0].Headline != "Day 1 headline" {
t.Fatalf("expected original discussion headline unchanged, got %q", original.Discussions[0].Headline)
}
if len(original.Discussions) != 3 {
t.Fatalf("expected original discussion slice unchanged, got %d entries", len(original.Discussions))
}
}
func TestServicePropagatesErrors(t *testing.T) {
@@ -362,6 +582,70 @@ func TestServicePropagatesErrors(t *testing.T) {
}
}
func testAlertRun() *model.WeatherAlertRun {
return testAlertRunWithAlerts([]model.WeatherAlert{
testAlert("current", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13), testTimePtr(12)),
testAlert("expired", "Update", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12), testTimePtr(13)),
testAlert("future-effective", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(15), testTimePtr(15)),
testAlert("canceled", " cancel ", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13), testTimePtr(13)),
testAlert("effective-at-boundary", "Alert", testTimePtr(9), testTimePtr(12), testTimePtr(12), testTimePtr(14), testTimePtr(14)),
testAlert("missing-effective", "Alert", testTimePtr(9), nil, nil, testTimePtr(14), testTimePtr(14)),
testAlert("missing-expires", "Alert", testTimePtr(9), testTimePtr(10), nil, nil, nil),
testAlert("later-onset", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(13), testTimePtr(14), testTimePtr(14)),
testAlert("ends-preferred", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(14), testTimePtr(11)),
testAlert("ends-at-boundary", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(12), testTimePtr(14)),
})
}
func testAlertRunWithAlerts(alerts []model.WeatherAlert) *model.WeatherAlertRun {
latitude := 38.62
longitude := -90.2
return &model.WeatherAlertRun{
LocationID: "stl",
LocationName: "St. Louis",
AsOf: testTime(10),
Latitude: &latitude,
Longitude: &longitude,
Alerts: alerts,
}
}
func testAlert(id string, messageType string, sent *time.Time, effective *time.Time, onset *time.Time, ends *time.Time, expires *time.Time) model.WeatherAlert {
refSent := testTime(8)
return model.WeatherAlert{
ID: id,
Event: "Thunderstorm Warning",
Headline: "Storm headline",
Severity: "Severe",
Urgency: "Immediate",
Certainty: "Likely",
Status: "Actual",
MessageType: messageType,
Category: "Met",
Response: "Shelter",
Description: "Storm description",
Instruction: "Take shelter",
Sent: sent,
Effective: effective,
Onset: onset,
Ends: ends,
Expires: expires,
AreaDescription: "St. Louis City",
SenderName: "NWS St. Louis",
References: []model.AlertReference{{
ID: "ref-" + id,
Identifier: "identifier-" + id,
Sender: "sender-" + id,
Sent: &refSent,
}},
}
}
func testTimePtr(hour int) *time.Time {
value := testTime(hour)
return &value
}
func testOutlookRun() *model.WeatherOutlookRun {
latitude := 38.62
longitude := -90.2
@@ -376,8 +660,25 @@ func testOutlookRun() *model.WeatherOutlookRun {
Outlooks: []model.WeatherOutlook{
testOutlook("cat-1", 1, "categorical", true, testTime(12), testTime(18), 5, `["cat"]`),
testOutlook("tor-1", 1, "tornado", true, testTime(13), testTime(19), 7, `["tor"]`),
testOutlook("day-2", 2, "wind", false, testTime(18), testTime(24), 2, `["wind"]`),
testOutlook("day-2", 2, "wind", true, testTime(18), testTime(24), 2, `["wind"]`),
},
Discussions: []model.WeatherOutlookDiscussion{
testOutlookDiscussion(1),
testOutlookDiscussion(2),
testOutlookDiscussion(3),
},
}
}
func testOutlookDiscussion(day int) model.WeatherOutlookDiscussion {
updatedAt := testTime(9 + day)
dayText := strconv.Itoa(day)
return model.WeatherOutlookDiscussion{
Day: day,
Headline: "Day " + dayText + " headline",
Summary: "Day " + dayText + " summary",
Discussion: "Day " + dayText + " discussion",
UpdatedAt: &updatedAt,
}
}
@@ -396,9 +697,6 @@ func testOutlook(id string, day int, outlookType string, containsLocation bool,
IssuedAt: validFrom.Add(-time.Hour),
ExpiresAt: validTo,
Forecaster: "DIAL",
Headline: id + " headline",
Summary: id + " summary",
Discussion: id + " discussion",
SourceURL: "https://example.test/" + id,
ImageURL: "https://example.test/" + id + ".png",
ContainsLocation: containsLocation,
@@ -424,3 +722,33 @@ func assertOutlookIDs(t *testing.T, run *model.WeatherOutlookRun, want []string)
}
}
}
func assertDiscussionDays(t *testing.T, run *model.WeatherOutlookRun, want []int) {
t.Helper()
if run == nil {
t.Fatal("expected outlook run")
}
if len(run.Discussions) != len(want) {
t.Fatalf("expected discussion days %v, got %+v", want, run.Discussions)
}
for i := range want {
if run.Discussions[i].Day != want[i] {
t.Fatalf("expected discussion days %v, got %+v", want, run.Discussions)
}
}
}
func assertAlertIDs(t *testing.T, run *model.WeatherAlertRun, want []string) {
t.Helper()
if run == nil {
t.Fatal("expected alert run")
}
if len(run.Alerts) != len(want) {
t.Fatalf("expected alert IDs %v, got %+v", want, run.Alerts)
}
for i := range want {
if run.Alerts[i].ID != want[i] {
t.Fatalf("expected alert IDs %v, got %+v", want, run.Alerts)
}
}
}

View File

@@ -11,6 +11,9 @@ Headline: {{$alert.Headline}}
{{- if $alert.Severity}}
Severity: {{$alert.Severity}}
{{- end}}
{{- if $alert.Ends}}
Ends: {{$alert.Ends}}
{{- end}}
{{- if $alert.Expires}}
Expires: {{$alert.Expires}}
{{- end}}

View File

@@ -1,5 +1,6 @@
{{- if .Data -}}
Current Conditions
Condition Code: {{.Data.ConditionCode}}
{{- if .Data.ConditionText}}
Condition: {{.Data.ConditionText}}
{{- end}}

View File

@@ -15,16 +15,28 @@ Contains Location: {{$outlook.ContainsLocation}}
{{- if $outlook.LabelText}}
Label Text: {{$outlook.LabelText}}
{{- end}}
{{- if $outlook.Headline}}
Headline: {{$outlook.Headline}}
{{- end}}
{{- if $outlook.Summary}}
Summary: {{$outlook.Summary}}
{{- end}}
{{- if $outlook.SourceURL}}
Source URL: {{$outlook.SourceURL}}
{{- end}}
{{- end}}
Discussions: {{len .Data.Discussions}}
{{- range $i, $discussion := .Data.Discussions}}
[{{$i}}] Day {{$discussion.Day}} Discussion
{{- if $discussion.UpdatedAt}}
Updated At: {{$discussion.UpdatedAt}}
{{- end}}
{{- if $discussion.Headline}}
Headline: {{$discussion.Headline}}
{{- end}}
{{- if $discussion.Summary}}
Summary: {{$discussion.Summary}}
{{- end}}
{{- if $discussion.Discussion}}
Discussion: {{$discussion.Discussion}}
{{- end}}
{{- end}}
{{- else -}}
No convective outlook data available.
{{- end}}