Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3136131519 | |||
| 8578db99b3 | |||
| 86fd848a6f | |||
| 6f8e01729d | |||
| 3c7ba89e15 | |||
| cfe6748330 | |||
| 5a1134b955 | |||
| 2a33fe01cf | |||
| f4dd701204 | |||
| 6316783c3a | |||
| cdfe8881d9 | |||
| 8d0bc90f9d | |||
| 12a7447494 | |||
| dd2f24316c | |||
| 90abc536dd |
@@ -26,14 +26,11 @@ database credentials through your normal secret-management process.
|
|||||||
- Hourly and narrative forecasts
|
- Hourly and narrative forecasts
|
||||||
- Forecast discussions
|
- Forecast discussions
|
||||||
- Weather stories
|
- Weather stories
|
||||||
- Convective outlooks:
|
- Convective outlooks
|
||||||
- `GET /outlooks/convective`
|
|
||||||
- `GET /outlooks/convective/active`
|
|
||||||
- `GET /outlooks/convective/location`
|
|
||||||
|
|
||||||
Common query parameters include `format`, `units`, and route-specific options
|
Common query parameters include `format`, `units`, and route-specific options
|
||||||
such as forecast `precision`, timezone `tz` / `TZ`, and outlook filters
|
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.
|
See [`docs/api.md`](docs/api.md) for the HTTP contract.
|
||||||
|
|
||||||
|
|||||||
93
docs/api.md
93
docs/api.md
@@ -162,8 +162,10 @@ GET /observations?units=us&precision=1
|
|||||||
GET /conditions/current
|
GET /conditions/current
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns current conditions aggregated from recent `observations` rows. The
|
Returns current conditions from recent `observations` rows. Numeric fields are
|
||||||
implemented observation window is 30 minutes.
|
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`.
|
Query parameters: `format`, `units`, `precision`.
|
||||||
|
|
||||||
@@ -171,6 +173,7 @@ Common `data` fields:
|
|||||||
|
|
||||||
| Field | Type | Notes |
|
| 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 |
|
| `conditionText` | string | optional text derived from WMO code and day/night flag |
|
||||||
| `isDay` | boolean | optional |
|
| `isDay` | boolean | optional |
|
||||||
| `relativeHumidityPercent` | number | optional |
|
| `relativeHumidityPercent` | number | optional |
|
||||||
@@ -194,10 +197,15 @@ GET /conditions/current?format=json&precision=0
|
|||||||
GET /alerts/active
|
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`.
|
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:
|
Run `data` fields:
|
||||||
|
|
||||||
| Field | Type | Notes |
|
| Field | Type | Notes |
|
||||||
@@ -207,11 +215,21 @@ Run `data` fields:
|
|||||||
| `latitude`, `longitude` | number | optional |
|
| `latitude`, `longitude` | number | optional |
|
||||||
| `alerts` | array | active alerts, possibly empty |
|
| `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`,
|
Alert fields include `id`, `event`, `headline`, `severity`, `urgency`,
|
||||||
`certainty`, `status`, `messageType`, `category`, `response`, `description`,
|
`certainty`, `status`, `messageType`, `category`, `response`, `description`,
|
||||||
`instruction`, `sent`, `effective`, `onset`, `expires`, `areaDescription`,
|
`instruction`, `sent`, `effective`, `onset`, `ends`, `expires`,
|
||||||
`senderName`, and `references`. Most alert fields are optional except `id` when
|
`areaDescription`, `senderName`, and `references`. Most alert fields are
|
||||||
an alert item is present.
|
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`.
|
Reference fields are `id`, `identifier`, `sender`, and `sent`.
|
||||||
|
|
||||||
@@ -226,7 +244,6 @@ GET /alerts/active?format=text
|
|||||||
```http
|
```http
|
||||||
GET /outlooks/convective
|
GET /outlooks/convective
|
||||||
GET /outlooks/convective/active
|
GET /outlooks/convective/active
|
||||||
GET /outlooks/convective/location
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns the latest SPC convective outlook run reconstructed from
|
Returns the latest SPC convective outlook run reconstructed from
|
||||||
@@ -238,9 +255,6 @@ Route behavior:
|
|||||||
outlook polygons unless user filters are supplied.
|
outlook polygons unless user filters are supplied.
|
||||||
- `/outlooks/convective/active` adds an active-time filter using the server's
|
- `/outlooks/convective/active` adds an active-time filter using the server's
|
||||||
current UTC time. Outlooks are active when `validFrom <= now < validTo`.
|
current UTC time. Outlooks are active when `validFrom <= now < validTo`.
|
||||||
- `/outlooks/convective/location` adds the same active-time filter and
|
|
||||||
`containsLocation=true`. It remains as an active local-outlook compatibility
|
|
||||||
route under the v2 weatherfeeder contract.
|
|
||||||
|
|
||||||
When no latest run exists, `data` is null. When a run exists but filters match
|
When no latest run exists, `data` is null. When a run exists but filters match
|
||||||
no outlooks, `data` remains an object and `outlooks` and `discussions` are
|
no outlooks, `data` remains an object and `outlooks` and `discussions` are
|
||||||
@@ -254,14 +268,10 @@ Query parameters:
|
|||||||
| `format`, `units`, `tz` / `TZ` | all outlook routes | shared rules above |
|
| `format`, `units`, `tz` / `TZ` | all outlook routes | shared rules above |
|
||||||
| `day` | all outlook routes | `1`, `2`, or `3` |
|
| `day` | all outlook routes | `1`, `2`, or `3` |
|
||||||
| `outlookType` | all outlook routes | `categorical`, `tornado`, `hail`, or `wind` |
|
| `outlookType` | all outlook routes | `categorical`, `tornado`, `hail`, or `wind` |
|
||||||
| `containsLocation` | `/outlooks/convective`, `/outlooks/convective/active` | boolean |
|
|
||||||
|
|
||||||
`outlookType` values are normalized case-insensitively. Weatherfeeder v2
|
`outlookType` values are normalized case-insensitively. Weatherfeeder v2
|
||||||
outlooks are already filtered for the configured location, so
|
outlooks are already filtered for the configured location. `precision`,
|
||||||
`containsLocation` is expected to be true for stored v2 outlooks.
|
`containsLocation`, and unknown parameters are rejected.
|
||||||
`containsLocation` is rejected on `/outlooks/convective/location` because that
|
|
||||||
route always applies `containsLocation=true`. `precision` and unknown
|
|
||||||
parameters are rejected.
|
|
||||||
|
|
||||||
Run `data` fields:
|
Run `data` fields:
|
||||||
|
|
||||||
@@ -306,7 +316,7 @@ Examples:
|
|||||||
|
|
||||||
```http
|
```http
|
||||||
GET /outlooks/convective?day=1&outlookType=categorical
|
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:
|
Example JSON response:
|
||||||
@@ -359,53 +369,8 @@ Example JSON response:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Example location-filtered JSON response:
|
Text format uses the shared convective outlook template for both outlook routes
|
||||||
|
and renders a no-data message when `data` is null.
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"locationId": "stl",
|
|
||||||
"asOf": "2026-06-11T18:00:00Z",
|
|
||||||
"outlooks": [
|
|
||||||
{
|
|
||||||
"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": "Polygon",
|
|
||||||
"coordinates": [
|
|
||||||
[
|
|
||||||
[-90.6, 38.4],
|
|
||||||
[-90.0, 38.4],
|
|
||||||
[-90.0, 38.8],
|
|
||||||
[-90.6, 38.4]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"discussions": [
|
|
||||||
{
|
|
||||||
"day": 1,
|
|
||||||
"headline": "Tornado risk near the configured location",
|
|
||||||
"discussion": "SPC tornado outlook 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.
|
|
||||||
|
|
||||||
### Forecasts
|
### Forecasts
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,10 @@ Latest parent resources use these ordering rules:
|
|||||||
story_order ASC, story_index ASC`.
|
story_order ASC, story_index ASC`.
|
||||||
- convective outlook runs: `as_of DESC, event_emitted_at DESC`.
|
- convective outlook runs: `as_of DESC, event_emitted_at DESC`.
|
||||||
|
|
||||||
Current conditions aggregate `observations` rows where `observed_at` is inside
|
Current conditions aggregate numeric values from `observations` rows where
|
||||||
the application-provided observation window.
|
`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
|
## Child Ordering
|
||||||
|
|
||||||
@@ -79,15 +81,16 @@ The repository reads only these columns.
|
|||||||
|
|
||||||
### `observations`
|
### `observations`
|
||||||
|
|
||||||
`event_id`, `station_id`, `station_name`, `observed_at`, `condition_code`,
|
`event_id`, `event_source`, `station_id`, `station_name`, `observed_at`,
|
||||||
`is_day`, `text_description`, `temperature_c`, `dewpoint_c`,
|
`condition_code`, `is_day`, `text_description`, `temperature_c`, `dewpoint_c`,
|
||||||
`wind_direction_degrees`, `wind_speed_kmh`, `wind_gust_kmh`,
|
`wind_direction_degrees`, `wind_speed_kmh`, `wind_gust_kmh`,
|
||||||
`barometric_pressure_pa`, `visibility_meters`, `relative_humidity_percent`,
|
`barometric_pressure_pa`, `visibility_meters`, `relative_humidity_percent`,
|
||||||
`apparent_temperature_c`, and `event_emitted_at`.
|
`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
|
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`
|
### `observation_present_weather`
|
||||||
|
|
||||||
@@ -105,8 +108,8 @@ and `event_emitted_at`.
|
|||||||
|
|
||||||
`alert_index`, `alert_id`, `event`, `headline`, `severity`, `urgency`,
|
`alert_index`, `alert_id`, `event`, `headline`, `severity`, `urgency`,
|
||||||
`certainty`, `status`, `message_type`, `category`, `response`, `description`,
|
`certainty`, `status`, `message_type`, `category`, `response`, `description`,
|
||||||
`instruction`, `sent`, `effective`, `onset`, `expires`, `area_description`,
|
`instruction`, `sent`, `effective`, `onset`, `ends`, `expires`,
|
||||||
`sender_name`, and `run_event_id`.
|
`area_description`, `sender_name`, and `run_event_id`.
|
||||||
|
|
||||||
### `alert_references`
|
### `alert_references`
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ This document describes the internal HTTP adapter under
|
|||||||
|
|
||||||
The HTTP adapter turns feedapi route definitions into calls on the application
|
The HTTP adapter turns feedapi route definitions into calls on the application
|
||||||
service boundary. It owns route registration, query binding, request validation,
|
service boundary. It owns route registration, query binding, request validation,
|
||||||
forecast day-slice filtering, outlook active/location filter construction,
|
forecast day-slice filtering, outlook active filter construction,
|
||||||
response envelopes, and template names.
|
alert active-time selection, response envelopes, and template names.
|
||||||
|
|
||||||
## Inputs and Outputs
|
## Inputs and Outputs
|
||||||
|
|
||||||
@@ -35,7 +35,8 @@ The adapter may:
|
|||||||
- call the `Service` interface;
|
- call the `Service` interface;
|
||||||
- choose the presenter function for an endpoint;
|
- choose the presenter function for an endpoint;
|
||||||
- filter forecast copies for `/today` and `/tomorrow`;
|
- 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:
|
The adapter must not:
|
||||||
|
|
||||||
@@ -61,9 +62,9 @@ declared in endpoint definitions, but `templates.base_dir` is loaded by feedapi.
|
|||||||
|
|
||||||
## State
|
## State
|
||||||
|
|
||||||
The adapter has no durable state. `forecastNow` and `outlookNow` are
|
The adapter has no durable state. `forecastNow`, `alertNow`, and `outlookNow`
|
||||||
package-level state only to make time-dependent endpoint tests deterministic.
|
are package-level state only to make time-dependent endpoint tests
|
||||||
Do not add request caches or cross-request mutable state here.
|
deterministic. Do not add request caches or cross-request mutable state here.
|
||||||
|
|
||||||
## Route Registry
|
## 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
|
available only through binders used by forecast, discussion, weather story, and
|
||||||
outlook routes.
|
outlook routes.
|
||||||
|
|
||||||
Outlook routes accept `day`, `outlookType`, and, except for
|
Outlook routes accept `day` and `outlookType`. `containsLocation` is a response
|
||||||
`/outlooks/convective/location`, `containsLocation`. The location route always
|
field only and is rejected as a request parameter.
|
||||||
adds `containsLocation=true` after binding and rejects an explicit
|
|
||||||
`containsLocation` query value.
|
|
||||||
|
|
||||||
## Timezone Parsing
|
## Timezone Parsing
|
||||||
|
|
||||||
@@ -131,23 +130,30 @@ Filtering behavior:
|
|||||||
The package variable `forecastNow` exists so endpoint tests can make day-slice
|
The package variable `forecastNow` exists so endpoint tests can make day-slice
|
||||||
behavior deterministic.
|
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 Filters
|
||||||
|
|
||||||
Outlook route filters are built at the HTTP boundary and passed to the
|
Outlook route filters are built at the HTTP boundary and passed to the
|
||||||
application service:
|
application service:
|
||||||
|
|
||||||
- `/outlooks/convective` uses only user-supplied filters;
|
- `/outlooks/convective` uses only user-supplied filters;
|
||||||
- `/outlooks/convective/active` adds `ActiveAt=outlookNow().UTC()`;
|
- `/outlooks/convective/active` adds `ActiveAt=outlookNow().UTC()`.
|
||||||
- `/outlooks/convective/location` adds the same active timestamp and
|
|
||||||
`ContainsLocation=true`.
|
|
||||||
|
|
||||||
The package variable `outlookNow` exists so endpoint tests can make active and
|
The package variable `outlookNow` exists so endpoint tests can make active
|
||||||
location filtering deterministic.
|
filtering deterministic.
|
||||||
|
|
||||||
The application service returns filtered outlook copies and trims run-level
|
The application service returns filtered outlook copies and trims run-level
|
||||||
discussions to days represented by retained outlooks.
|
discussions to days represented by retained outlooks.
|
||||||
`/outlooks/convective/location` is retained for compatibility and active
|
|
||||||
local-outlook behavior under the weatherfeeder outlook v2 contract.
|
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
|
|||||||
@@ -76,10 +76,13 @@ successful responses with `data: null`.
|
|||||||
|
|
||||||
- `LatestObservation`: latest row from `observations`, then present-weather
|
- `LatestObservation`: latest row from `observations`, then present-weather
|
||||||
rows from `observation_present_weather`.
|
rows from `observation_present_weather`.
|
||||||
- `CurrentConditions`: aggregates recent rows from `observations` using the
|
- `CurrentConditions`: reads an aggregate row from recent `observations` using
|
||||||
application-provided observation window.
|
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
|
- `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'`,
|
- `LatestHourlyForecast`: latest `forecasts` row where `product = 'hourly'`,
|
||||||
then child `forecast_periods`.
|
then child `forecast_periods`.
|
||||||
- `LatestNarrativeForecast`: latest `forecasts` row where
|
- `LatestNarrativeForecast`: latest `forecasts` row where
|
||||||
|
|||||||
@@ -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.
|
`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
|
## Configuration and CLI Boundaries
|
||||||
|
|
||||||
|
|||||||
@@ -1,524 +0,0 @@
|
|||||||
# Implement Weatherfeeder Outlook V2 Support
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Implementation, current-behavior documentation, and automated verification are
|
|
||||||
complete. This file now serves as the checklist and verification record for the
|
|
||||||
outlook v2 compatibility update. Authoritative implemented behavior is
|
|
||||||
documented in `docs/api.md`, `docs/integrations/weatherfeeder-postgres.md`, and
|
|
||||||
`docs/internal/`.
|
|
||||||
|
|
||||||
## Verification Record
|
|
||||||
|
|
||||||
Completed on 2026-06-12:
|
|
||||||
|
|
||||||
- `go test ./internal/app`
|
|
||||||
- `go test ./internal/adapters/outbound/postgres`
|
|
||||||
- `go test ./internal/adapters/inbound/httpapi/presenter`
|
|
||||||
- `go test ./internal/adapters/inbound/httpapi`
|
|
||||||
- `go test ./...`
|
|
||||||
- `go build ./cmd/weatherapi`
|
|
||||||
|
|
||||||
The database-backed endpoint checklist requires a reachable PostgreSQL database
|
|
||||||
populated by weatherfeeder outlook v2 tables. It was not run in this workspace
|
|
||||||
because no such populated database was available.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Implement `weatherapi` support for the `weatherfeeder` SPC outlook v2 contract described in `docs/roadmap/outlook.md`.
|
|
||||||
|
|
||||||
This is a compatibility update for the existing convective outlook route family. Do not add or remove public routes. Update dependency, repository reads, app filtering, presenters, templates, tests, and current-behavior docs so `weatherapi` reads `weather.outlook.v2` data from the new weatherfeeder Postgres table shape.
|
|
||||||
|
|
||||||
## Required End State
|
|
||||||
|
|
||||||
- `go.mod` depends on the released `weatherfeeder` version containing `weather.outlook.v2` support.
|
|
||||||
- Existing public routes remain available:
|
|
||||||
- `GET /outlooks/convective`
|
|
||||||
- `GET /outlooks/convective/active`
|
|
||||||
- `GET /outlooks/convective/location`
|
|
||||||
- `weatherapi` remains read-only and does not create, migrate, or repair weatherfeeder tables.
|
|
||||||
- The Postgres repository reads `outlook_runs`, `outlooks`, and `outlook_discussions`.
|
|
||||||
- The repository no longer expects polygon-level `headline`, `summary`, or `discussion` columns on `outlooks`.
|
|
||||||
- Returned `WeatherOutlookRun` values include run-level `Discussions` loaded from `outlook_discussions`.
|
|
||||||
- App-level filtering trims both `Outlooks` and `Discussions` so discussions are present only for days with retained outlooks.
|
|
||||||
- Presenters copy and timezone-convert run-level discussion `updatedAt` values.
|
|
||||||
- JSON/XML/text responses expose run-level `discussions` and do not expose polygon-level outlook prose fields.
|
|
||||||
- `data: null` semantics for no latest run are preserved.
|
|
||||||
- Filtered no-match responses return a non-null run with `outlooks: []` and `discussions: []`.
|
|
||||||
- Public query behavior remains compatible: `format`, `units`, `tz` / `TZ`, `day`, `outlookType`, and allowed `containsLocation` behavior are preserved; `precision` and unknown params remain rejected.
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- Do not implement weatherfeeder table creation or migration in weatherapi.
|
|
||||||
- Do not call SPC or any upstream weather provider from weatherapi.
|
|
||||||
- Do not introduce historical browsing or cross-run accumulation in this change.
|
|
||||||
- Do not remove `/outlooks/convective/location`; keep it as an API-compatible active local-outlook route.
|
|
||||||
- Do not keep stale v1 SQL reads for `outlooks.headline`, `outlooks.summary`, or `outlooks.discussion`.
|
|
||||||
- Do not commit a `replace` directive for `weatherfeeder`.
|
|
||||||
- Keep SQL in `*_queries.go`, row DTOs in `*_rows.go`, row mapping in `*_mapper.go`, and request/presentation policy in HTTP/presenter packages.
|
|
||||||
|
|
||||||
## Stage 1: Dependency And Compile Boundary
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
1. Update `go.mod` to the released `gitea.maximumdirect.net/ejr/weatherfeeder` version that includes:
|
|
||||||
- `standards.SchemaWeatherOutlookV2`;
|
|
||||||
- `model.WeatherOutlookRun.Discussions`;
|
|
||||||
- `model.WeatherOutlookDiscussion`;
|
|
||||||
- `model.WeatherOutlook` without `Headline`, `Summary`, or `Discussion`.
|
|
||||||
2. Run `go mod tidy`.
|
|
||||||
3. Fix compile errors from removed polygon-level outlook prose fields.
|
|
||||||
4. Search for outlook v1 and polygon prose references:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
rg "SchemaWeatherOutlookV1|weather\.outlook\.v1|\.Headline|\.Summary|\.Discussion|headline|summary|discussion" internal docs templates README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Treat matches carefully:
|
|
||||||
- Keep unrelated alert and forecast-discussion uses.
|
|
||||||
- Remove or update outlook polygon-level prose references.
|
|
||||||
- Replace outlook-specific schema references with v2 where applicable.
|
|
||||||
|
|
||||||
### Expected Files
|
|
||||||
|
|
||||||
- `go.mod`
|
|
||||||
- `go.sum`
|
|
||||||
- `internal/adapters/outbound/postgres/outlooks_rows.go`
|
|
||||||
- `internal/adapters/outbound/postgres/outlooks_mapper.go`
|
|
||||||
- `internal/adapters/outbound/postgres/outlooks_queries.go`
|
|
||||||
- `internal/adapters/outbound/postgres/outlooks_read.go`
|
|
||||||
- `internal/adapters/inbound/httpapi/presenter/outlook.go`
|
|
||||||
- `internal/app/service.go`
|
|
||||||
- outlook-related tests under `internal/app`, `internal/adapters/outbound/postgres`, `internal/adapters/inbound/httpapi`, and presenter tests
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
|
|
||||||
At this stage, run a compile-oriented focused set. It may fail until later stages are complete, but failures should identify remaining v1/prose references.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/app ./internal/adapters/outbound/postgres ./internal/adapters/inbound/httpapi/presenter
|
|
||||||
```
|
|
||||||
|
|
||||||
## Stage 2: Postgres Query, Row, And Mapper Updates
|
|
||||||
|
|
||||||
### Query Changes
|
|
||||||
|
|
||||||
Update `internal/adapters/outbound/postgres/outlooks_queries.go`.
|
|
||||||
|
|
||||||
Parent query:
|
|
||||||
|
|
||||||
- Keep latest run ordering:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
ORDER BY as_of DESC, event_emitted_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
```
|
|
||||||
|
|
||||||
- Continue selecting parent fields needed by `WeatherOutlookRun`.
|
|
||||||
- Optionally select `discussion_count` for test/sanity visibility, but do not expose it in the API response model.
|
|
||||||
|
|
||||||
Outlook child query:
|
|
||||||
|
|
||||||
- Remove these v1 columns from the `SELECT` list:
|
|
||||||
- `headline`
|
|
||||||
- `summary`
|
|
||||||
- `discussion`
|
|
||||||
- Continue selecting:
|
|
||||||
- `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`
|
|
||||||
- Keep `WHERE run_event_id = $1`.
|
|
||||||
- Keep `ORDER BY outlook_index ASC`.
|
|
||||||
|
|
||||||
Discussion child query:
|
|
||||||
|
|
||||||
- Add `queryOutlookDiscussionsForRun`:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
discussion_index,
|
|
||||||
day,
|
|
||||||
headline,
|
|
||||||
summary,
|
|
||||||
discussion,
|
|
||||||
updated_at
|
|
||||||
FROM outlook_discussions
|
|
||||||
WHERE run_event_id = $1
|
|
||||||
ORDER BY discussion_index ASC
|
|
||||||
```
|
|
||||||
|
|
||||||
### Row DTO Changes
|
|
||||||
|
|
||||||
Update `internal/adapters/outbound/postgres/outlooks_rows.go`:
|
|
||||||
|
|
||||||
- Remove `Headline`, `Summary`, and `Discussion` from `outlookRow`.
|
|
||||||
- Add `DiscussionCount` to `outlookRunParentRow` only if selected by parent query.
|
|
||||||
- Add `outlookDiscussionRow`:
|
|
||||||
- `DiscussionIndex int`
|
|
||||||
- `Day int`
|
|
||||||
- `Headline sql.NullString`
|
|
||||||
- `Summary sql.NullString`
|
|
||||||
- `Discussion sql.NullString`
|
|
||||||
- `UpdatedAt sql.NullTime`
|
|
||||||
|
|
||||||
### Mapper Changes
|
|
||||||
|
|
||||||
Update `internal/adapters/outbound/postgres/outlooks_mapper.go`:
|
|
||||||
|
|
||||||
- `mapOutlookRow` maps only v2 polygon fields.
|
|
||||||
- Keep geometry validation with `json.Valid` and return contextual mapper errors for invalid JSON.
|
|
||||||
- Continue copying geometry bytes with `append([]byte(nil), geometry...)`.
|
|
||||||
- Continue normalizing outlook timestamps to UTC.
|
|
||||||
- Add `mapOutlookDiscussionRow` returning `model.WeatherOutlookDiscussion`:
|
|
||||||
- `Day` from row day;
|
|
||||||
- string fields via existing `stringValue` helper;
|
|
||||||
- `UpdatedAt` via existing `timePtr` helper, ensuring UTC normalization.
|
|
||||||
|
|
||||||
### Read Flow Changes
|
|
||||||
|
|
||||||
Update `internal/adapters/outbound/postgres/outlooks_read.go`:
|
|
||||||
|
|
||||||
- `LatestConvectiveOutlookRun` loads parent row as today.
|
|
||||||
- After parent row:
|
|
||||||
- call `loadOutlooks(ctx, row.EventID)`;
|
|
||||||
- call `loadOutlookDiscussions(ctx, row.EventID)`;
|
|
||||||
- attach both to the run.
|
|
||||||
- Add `loadOutlookDiscussions` mirroring `loadOutlooks` style:
|
|
||||||
- query with context;
|
|
||||||
- scan rows;
|
|
||||||
- map rows;
|
|
||||||
- return iteration errors;
|
|
||||||
- wrap query, scan, map, and iteration errors with operation context.
|
|
||||||
- Preserve `nil, nil` on missing parent row.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
Update or add tests in `internal/adapters/outbound/postgres`:
|
|
||||||
|
|
||||||
- `mapOutlookRow` maps v2 polygon fields and no longer expects polygon-level prose.
|
|
||||||
- `mapOutlookRow` rejects invalid `geometry_json`.
|
|
||||||
- nullable label/forecaster/source/image/severity fields map correctly.
|
|
||||||
- `mapOutlookDiscussionRow` maps day/headline/summary/discussion/updatedAt and normalizes time to UTC.
|
|
||||||
- nullable discussion fields map to empty/omitted canonical values.
|
|
||||||
- latest run read loads outlooks in `outlook_index ASC` order.
|
|
||||||
- latest run read loads discussions in `discussion_index ASC` order.
|
|
||||||
- missing latest parent returns `nil, nil`.
|
|
||||||
- query/scan/iteration failures remain context-wrapped.
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/adapters/outbound/postgres
|
|
||||||
```
|
|
||||||
|
|
||||||
## Stage 3: Application Filtering And Copy Semantics
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
Update `internal/app/service.go`.
|
|
||||||
|
|
||||||
Clone behavior:
|
|
||||||
|
|
||||||
- `cloneOutlookRun` must deep-copy `Discussions` in addition to `Outlooks`.
|
|
||||||
- Ensure outlook severity pointers, geometry bytes, latitude/longitude pointers, and issuedAt pointers remain copied.
|
|
||||||
- Add a helper such as `cloneOutlookDiscussion` if useful.
|
|
||||||
|
|
||||||
Filtering behavior:
|
|
||||||
|
|
||||||
- Filter `Outlooks` as today using `OutlookFilter`.
|
|
||||||
- After filtering outlooks, filter `Discussions` to only days represented by retained outlooks.
|
|
||||||
- Preserve discussion order from the repository result.
|
|
||||||
- If no outlooks remain, set discussions to an empty slice, not stale unfiltered discussions.
|
|
||||||
- Preserve nil/missing run behavior: repository `nil, nil` still returns `nil, nil`.
|
|
||||||
|
|
||||||
Recommended helper shape:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func filterOutlookDiscussions(discussions []model.WeatherOutlookDiscussion, outlooks []model.WeatherOutlook) []model.WeatherOutlookDiscussion
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Build a set of retained outlook days.
|
|
||||||
- If the set is empty, return `[]model.WeatherOutlookDiscussion{}` when discussions was non-nil or when consistent empty-array output is desired.
|
|
||||||
- Include each discussion only if `discussion.Day` is in the set.
|
|
||||||
- Do not synthesize discussions.
|
|
||||||
|
|
||||||
### Query Compatibility
|
|
||||||
|
|
||||||
Do not change query binder behavior in this stage unless tests reveal compile fallout from the model update. Public query behavior remains:
|
|
||||||
|
|
||||||
- `day` accepted on all outlook routes.
|
|
||||||
- `outlookType` accepted on all outlook routes.
|
|
||||||
- `containsLocation` accepted on `/outlooks/convective` and `/outlooks/convective/active`.
|
|
||||||
- `containsLocation` rejected on `/outlooks/convective/location`.
|
|
||||||
- `format`, `units`, and `tz` / `TZ` accepted.
|
|
||||||
- `precision` and unknown params rejected.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
Update `internal/app/service_test.go`:
|
|
||||||
|
|
||||||
- Delegation test still asserts one repository call.
|
|
||||||
- Day filter retains matching outlooks and only matching day discussions.
|
|
||||||
- Outlook type filter retains discussions only for days with retained outlooks.
|
|
||||||
- Active filter retains discussions only for days with active retained outlooks.
|
|
||||||
- `containsLocation=false` returns an empty outlook/discussion run for v2-style test data.
|
|
||||||
- Filtered no-match result has empty `Outlooks` and empty `Discussions`.
|
|
||||||
- Mutating returned outlooks/discussions does not mutate repository-owned data.
|
|
||||||
- Geometry byte copy and pointer copy behavior remains covered.
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/app
|
|
||||||
```
|
|
||||||
|
|
||||||
## Stage 4: Presenter And Text Template Updates
|
|
||||||
|
|
||||||
### Presenter Changes
|
|
||||||
|
|
||||||
Update `internal/adapters/inbound/httpapi/presenter/outlook.go`:
|
|
||||||
|
|
||||||
- Include `Discussions` when constructing the presented `model.WeatherOutlookRun`.
|
|
||||||
- Convert `WeatherOutlookDiscussion.UpdatedAt` using `inLocationTimePtr`.
|
|
||||||
- Deep-copy discussions so presenter output mutation cannot affect repository/app input.
|
|
||||||
- Remove `Headline`, `Summary`, and `Discussion` assignments from `copyOutlook`.
|
|
||||||
- Continue converting:
|
|
||||||
- run `AsOf`;
|
|
||||||
- run `IssuedAt`;
|
|
||||||
- outlook `ValidFrom`;
|
|
||||||
- outlook `ValidTo`;
|
|
||||||
- outlook `IssuedAt`;
|
|
||||||
- outlook `ExpiresAt`.
|
|
||||||
- Continue preserving geometry copy behavior.
|
|
||||||
- Continue returning `nil` for nil input.
|
|
||||||
|
|
||||||
Recommended helper:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func copyOutlookDiscussion(d model.WeatherOutlookDiscussion, tz *time.Location) model.WeatherOutlookDiscussion
|
|
||||||
```
|
|
||||||
|
|
||||||
### Template Changes
|
|
||||||
|
|
||||||
Update `templates/outlooks_convective.txt.tmpl`:
|
|
||||||
|
|
||||||
- Render run-level discussions from `.Data.Discussions`.
|
|
||||||
- Do not reference outlook-level `.Headline`, `.Summary`, or `.Discussion`.
|
|
||||||
- Keep no-data output when `.Data` is nil.
|
|
||||||
- Keep useful output when `.Data` exists and `.Data.Outlooks` is empty.
|
|
||||||
- Include discussion day/headline/summary/discussion fields in a readable, conditional way.
|
|
||||||
- Avoid printing zero timestamps or `<nil>` values for absent `updatedAt`.
|
|
||||||
|
|
||||||
### Presenter Tests
|
|
||||||
|
|
||||||
Update `internal/adapters/inbound/httpapi/presenter/payload_test.go`:
|
|
||||||
|
|
||||||
- `OutlookRunPayload(nil, ...)` returns nil.
|
|
||||||
- Output includes copied `Discussions`.
|
|
||||||
- Discussion `UpdatedAt` is converted to requested timezone.
|
|
||||||
- Mutating output discussions does not mutate input discussions.
|
|
||||||
- Mutating output geometry does not mutate input geometry.
|
|
||||||
- Outlook timestamps still convert to requested timezone.
|
|
||||||
- Polygon-level prose fields are not expected in output structs/tests.
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/adapters/inbound/httpapi/presenter
|
|
||||||
```
|
|
||||||
|
|
||||||
## Stage 5: HTTP Endpoint Behavior And Tests
|
|
||||||
|
|
||||||
### Handler Behavior
|
|
||||||
|
|
||||||
Keep route registration and handler structure unchanged unless compile updates require mechanical edits.
|
|
||||||
|
|
||||||
Ensure endpoint responses reflect the updated presenter/app behavior:
|
|
||||||
|
|
||||||
- `/outlooks/convective` returns latest run with v2 shape.
|
|
||||||
- `/outlooks/convective/active` applies active filter and trims discussions.
|
|
||||||
- `/outlooks/convective/location` applies active local filter and trims discussions.
|
|
||||||
- `data: null` remains unchanged when no run exists.
|
|
||||||
- Filtered no-match response returns data object with empty outlook and discussion arrays.
|
|
||||||
|
|
||||||
### Endpoint Tests
|
|
||||||
|
|
||||||
Update `internal/adapters/inbound/httpapi/endpoints_test.go`:
|
|
||||||
|
|
||||||
- test fakes build `model.WeatherOutlookRun` with `Discussions`.
|
|
||||||
- JSON success asserts run-level `discussions` are present.
|
|
||||||
- JSON success asserts outlook polygons do not include polygon-level prose.
|
|
||||||
- XML success renders run-level discussions without errors.
|
|
||||||
- Text success renders run-level discussion content.
|
|
||||||
- Timezone test checks conversion of:
|
|
||||||
- run `asOf`;
|
|
||||||
- run `issuedAt`;
|
|
||||||
- outlook times;
|
|
||||||
- discussion `updatedAt`.
|
|
||||||
- Null data test remains unchanged.
|
|
||||||
- Filtered no-match test expects both `outlooks` and `discussions` empty.
|
|
||||||
- Query acceptance still covers `units`, `format`, `tz` / `TZ`, `day`, `outlookType`, and allowed `containsLocation`.
|
|
||||||
- Query rejection still covers `precision`, unknown params, invalid day, invalid outlookType, invalid containsLocation, invalid timezone, conflicting `tz` / `TZ`, and `containsLocation` on `/location`.
|
|
||||||
- Active/location route tests continue using injectable `outlookNow`.
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/adapters/inbound/httpapi
|
|
||||||
```
|
|
||||||
|
|
||||||
## Stage 6: Documentation Updates
|
|
||||||
|
|
||||||
After code behavior is updated, update current-behavior documentation. Do not leave v2 behavior only in roadmap files.
|
|
||||||
|
|
||||||
### Public Docs
|
|
||||||
|
|
||||||
Update `docs/api.md`:
|
|
||||||
|
|
||||||
- State outlook endpoints serve weatherfeeder `weather.outlook.v2` data.
|
|
||||||
- Run fields include `discussions`.
|
|
||||||
- Add discussion field table:
|
|
||||||
- `day`;
|
|
||||||
- `headline`;
|
|
||||||
- `summary`;
|
|
||||||
- `discussion`;
|
|
||||||
- `updatedAt`.
|
|
||||||
- Remove polygon-level `headline`, `summary`, and `discussion` from outlook fields.
|
|
||||||
- State v2 outlooks are already location-filtered by weatherfeeder.
|
|
||||||
- State `containsLocation` is expected to be true for v2 outlooks.
|
|
||||||
- Explain `/outlooks/convective/location` remains an active local-outlook compatibility route under v2.
|
|
||||||
- State endpoint filters also filter `discussions` to days represented by retained outlooks.
|
|
||||||
- State current endpoints use latest-run semantics and do not accumulate historical active outlook rows.
|
|
||||||
- Update examples to show run-level `discussions`.
|
|
||||||
- Ensure examples use valid GeoJSON polygon or multipolygon, not a point, if demonstrating SPC geometry.
|
|
||||||
|
|
||||||
Update `README.md` only if its overview implies all-polygons or old response shape.
|
|
||||||
|
|
||||||
### Integration/Internal Docs
|
|
||||||
|
|
||||||
Update `docs/integrations/weatherfeeder-postgres.md`:
|
|
||||||
|
|
||||||
- Update dependency version.
|
|
||||||
- Table family for convective outlook includes `outlook_runs`, `outlooks`, and `outlook_discussions`.
|
|
||||||
- Latest run selection remains `as_of DESC, event_emitted_at DESC`.
|
|
||||||
- Child ordering includes:
|
|
||||||
- outlooks by `outlook_index ASC`;
|
|
||||||
- outlook discussions by `discussion_index ASC`.
|
|
||||||
- `outlook_runs` includes `discussion_count` if listed.
|
|
||||||
- `outlooks` no longer includes `headline`, `summary`, or `discussion`.
|
|
||||||
- Add `outlook_discussions` columns.
|
|
||||||
- State weatherapi assumes weatherfeeder's outlook v2 table reset has already been applied.
|
|
||||||
|
|
||||||
Update `docs/internal/postgres-repository.md`:
|
|
||||||
|
|
||||||
- `LatestConvectiveOutlookRun` loads parent, outlook children, and discussion children.
|
|
||||||
- Add outlook discussion child ordering.
|
|
||||||
|
|
||||||
Update `docs/internal/presenters.md`:
|
|
||||||
|
|
||||||
- Outlook presenter copies and timezone-converts run-level discussions.
|
|
||||||
- Outlook presenter no longer handles polygon-level prose.
|
|
||||||
|
|
||||||
Update `docs/internal/http-adapter.md`:
|
|
||||||
|
|
||||||
- Outlook filters trim discussions to retained outlook days.
|
|
||||||
- `/location` is retained for compatibility and active local-outlook behavior.
|
|
||||||
|
|
||||||
Update `docs/operations.md` and `docs/troubleshooting.md` only if they mention the old weatherfeeder table shape or should warn operators that weatherapi requires weatherfeeder v2 outlook tables.
|
|
||||||
|
|
||||||
### Roadmap Docs
|
|
||||||
|
|
||||||
After implementation is complete, replace or update `docs/roadmap/outlook.md` and `docs/roadmap/implementation.md` according to repository convention:
|
|
||||||
|
|
||||||
- Either mark the roadmap complete and point to current docs.
|
|
||||||
- Or move any remaining deferred ideas to a future roadmap file.
|
|
||||||
|
|
||||||
Do not leave roadmap files claiming unimplemented work after the implementation has shipped.
|
|
||||||
|
|
||||||
### Documentation Checks
|
|
||||||
|
|
||||||
Run searches for stale outlook v1/current behavior:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
rg "weather\.outlook\.v1|outlooks\.headline|outlooks\.summary|outlooks\.discussion|polygon-level|all stored outlook|all polygons|headline, summary, discussion" docs README.md internal templates
|
|
||||||
```
|
|
||||||
|
|
||||||
Allowed matches:
|
|
||||||
|
|
||||||
- explicit historical/legacy notes, if any;
|
|
||||||
- unrelated alert or forecast discussion prose fields;
|
|
||||||
- roadmap files that are intentionally historical.
|
|
||||||
|
|
||||||
## Stage 7: Full Verification
|
|
||||||
|
|
||||||
Run focused tests:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/app
|
|
||||||
go test ./internal/adapters/outbound/postgres
|
|
||||||
go test ./internal/adapters/inbound/httpapi/presenter
|
|
||||||
go test ./internal/adapters/inbound/httpapi
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the full suite:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional build check:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go build ./cmd/weatherapi
|
|
||||||
```
|
|
||||||
|
|
||||||
Manual verification against a database populated by weatherfeeder outlook v2 tables:
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /outlooks/convective
|
|
||||||
GET /outlooks/convective/active
|
|
||||||
GET /outlooks/convective/location
|
|
||||||
GET /outlooks/convective?day=1
|
|
||||||
GET /outlooks/convective?outlookType=tornado
|
|
||||||
GET /outlooks/convective?containsLocation=false
|
|
||||||
GET /outlooks/convective?format=text&tz=America/Chicago
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected manual results:
|
|
||||||
|
|
||||||
- Latest run includes run-level `discussions`.
|
|
||||||
- Active route filters outlooks and discussions together.
|
|
||||||
- Location route returns active local outlooks and remains valid.
|
|
||||||
- `containsLocation=false` returns an empty run for v2 data.
|
|
||||||
- Text format renders discussions without template errors.
|
|
||||||
- XML format renders without errors.
|
|
||||||
- No endpoint queries historical active rows across older runs.
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- A weatherfeeder release containing outlook v2 exists before final implementation is committed.
|
|
||||||
- Operators have reset/recreated weatherfeeder outlook tables according to weatherfeeder transition docs.
|
|
||||||
- Existing weatherapi route names remain stable for external consumers.
|
|
||||||
- Keeping `containsLocation` query support on non-location routes is useful backward compatibility even though v2 data should always be local.
|
|
||||||
- Latest-run semantics are correct for current convective outlook endpoints.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
None. This plan fixes the route, dependency, storage, filtering, presentation, and documentation decisions required to support weatherfeeder outlook v2 while preserving weatherapi's existing public route family.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# Weatherfeeder Outlook V2 Support
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Implemented in current behavior docs and code. The authoritative implemented
|
|
||||||
contracts now live in:
|
|
||||||
|
|
||||||
- [`docs/api.md`](../api.md) for public outlook routes, query parameters, and
|
|
||||||
response fields;
|
|
||||||
- [`docs/integrations/weatherfeeder-postgres.md`](../integrations/weatherfeeder-postgres.md)
|
|
||||||
for weatherfeeder-owned table assumptions;
|
|
||||||
- [`docs/internal/postgres-repository.md`](../internal/postgres-repository.md),
|
|
||||||
[`docs/internal/http-adapter.md`](../internal/http-adapter.md), and
|
|
||||||
[`docs/internal/presenters.md`](../internal/presenters.md) for internal
|
|
||||||
behavior.
|
|
||||||
|
|
||||||
`docs/roadmap/implementation.md` remains as the implementation checklist and
|
|
||||||
verification record for this compatibility update.
|
|
||||||
|
|
||||||
## Delivered Behavior
|
|
||||||
|
|
||||||
- Existing public routes remain available:
|
|
||||||
- `GET /outlooks/convective`
|
|
||||||
- `GET /outlooks/convective/active`
|
|
||||||
- `GET /outlooks/convective/location`
|
|
||||||
- The Postgres repository reads weatherfeeder outlook v2 tables:
|
|
||||||
`outlook_runs`, `outlooks`, and `outlook_discussions`.
|
|
||||||
- Outlook polygons no longer include prose fields.
|
|
||||||
- Run-level `discussions` are loaded, copied, presented, and timezone-converted.
|
|
||||||
- Application filtering trims `discussions` to days represented by retained
|
|
||||||
outlooks.
|
|
||||||
- Missing latest data still returns `data: null`.
|
|
||||||
- Filtered no-match responses return a run object with `outlooks: []` and
|
|
||||||
`discussions: []`.
|
|
||||||
- `weatherapi` remains read-only and does not create, migrate, or repair
|
|
||||||
weatherfeeder tables.
|
|
||||||
@@ -15,8 +15,8 @@ GET {{baseUrl}}/alerts/active?format=text
|
|||||||
GET {{baseUrl}}/outlooks/convective?day=1&outlookType=categorical
|
GET {{baseUrl}}/outlooks/convective?day=1&outlookType=categorical
|
||||||
Accept: application/json
|
Accept: application/json
|
||||||
|
|
||||||
### Active convective outlooks for the configured location as text
|
### Active convective outlooks as text
|
||||||
GET {{baseUrl}}/outlooks/convective/location?format=text&tz=CDT
|
GET {{baseUrl}}/outlooks/convective/active?format=text&tz=CDT
|
||||||
|
|
||||||
### Hourly forecast in US units
|
### Hourly forecast in US units
|
||||||
GET {{baseUrl}}/forecast/hourly?units=us&precision=1&tz=Chicago
|
GET {{baseUrl}}/forecast/hourly?units=us&precision=1&tz=Chicago
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -4,7 +4,7 @@ go 1.25.5
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.maximumdirect.net/ejr/feedapi v0.1.0
|
gitea.maximumdirect.net/ejr/feedapi v0.1.0
|
||||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0
|
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.1
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -1,7 +1,7 @@
|
|||||||
gitea.maximumdirect.net/ejr/feedapi v0.1.0 h1:ZB5QWKD5DPFV3P7vyeJqXPMcSWN9qHkDUHw1LgN9hwY=
|
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/feedapi v0.1.0/go.mod h1:3fIaFFx4ywt0TWbN8DIIBAHJn7ZQUm6PNcceqRgy3bw=
|
||||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0 h1:U3yln3o2rGqfMvWVRwOGgQeqYuqMm+/p0XIRhK8TDUQ=
|
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.1 h1:dZYDpOd0vEIk6QljsrhFyrOY0Lt4WyEoazhqDFIZKNQ=
|
||||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0/go.mod h1:VVtuwrbddWdUu21ovCSSojhH5J9P6kk0/dfnFqC4/Lw=
|
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 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
|
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
|
||||||
"gitea.maximumdirect.net/ejr/feedapi/render"
|
"gitea.maximumdirect.net/ejr/feedapi/render"
|
||||||
@@ -11,12 +12,14 @@ import (
|
|||||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
|
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var alertNow = time.Now
|
||||||
|
|
||||||
func alertsDefinition(svc Service) endpoint.Definition {
|
func alertsDefinition(svc Service) endpoint.Definition {
|
||||||
return endpoint.GET(
|
return endpoint.GET(
|
||||||
"/alerts/active",
|
"/alerts/active",
|
||||||
bindQuery,
|
bindQuery,
|
||||||
func(ctx context.Context, req queryRequest) (any, error) {
|
func(ctx context.Context, req queryRequest) (any, error) {
|
||||||
run, err := svc.LatestAlertRun(ctx)
|
run, err := svc.LatestActiveAlertRun(ctx, alertNow().UTC())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type fakeService struct {
|
|||||||
weatherStoryRun *model.WeatherStoryRun
|
weatherStoryRun *model.WeatherStoryRun
|
||||||
weatherStory *model.WeatherStory
|
weatherStory *model.WeatherStory
|
||||||
alerts *model.WeatherAlertRun
|
alerts *model.WeatherAlertRun
|
||||||
|
activeAlertAt []time.Time
|
||||||
outlookRun *model.WeatherOutlookRun
|
outlookRun *model.WeatherOutlookRun
|
||||||
outlookFilters []app.OutlookFilter
|
outlookFilters []app.OutlookFilter
|
||||||
conditions *app.CurrentConditions
|
conditions *app.CurrentConditions
|
||||||
@@ -64,6 +65,11 @@ func (s *fakeService) LatestAlertRun(context.Context) (*model.WeatherAlertRun, e
|
|||||||
return s.alerts, s.err
|
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) {
|
func (s *fakeService) LatestConvectiveOutlook(_ context.Context, filter app.OutlookFilter) (*model.WeatherOutlookRun, error) {
|
||||||
s.outlookFilters = append(s.outlookFilters, filter)
|
s.outlookFilters = append(s.outlookFilters, filter)
|
||||||
return s.outlookRun, s.err
|
return s.outlookRun, s.err
|
||||||
@@ -73,6 +79,46 @@ func (s *fakeService) CurrentConditions(context.Context) (*app.CurrentConditions
|
|||||||
return s.conditions, s.err
|
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) {
|
func TestObservationsRejectUnknownQueryParameter(t *testing.T) {
|
||||||
h := newHandler(t, &fakeService{}, "/observations")
|
h := newHandler(t, &fakeService{}, "/observations")
|
||||||
|
|
||||||
@@ -278,12 +324,14 @@ func TestObservationUSUnitsChangesFieldNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertsUSUnitsKeepSchema(t *testing.T) {
|
func TestAlertsUSUnitsKeepSchema(t *testing.T) {
|
||||||
|
ends := time.Date(2026, 6, 11, 14, 0, 0, 0, time.UTC)
|
||||||
h := newHandler(t, &fakeService{
|
h := newHandler(t, &fakeService{
|
||||||
alerts: &model.WeatherAlertRun{
|
alerts: &model.WeatherAlertRun{
|
||||||
AsOf: time.Now().UTC(),
|
AsOf: time.Now().UTC(),
|
||||||
Alerts: []model.WeatherAlert{{
|
Alerts: []model.WeatherAlert{{
|
||||||
ID: "abc",
|
ID: "abc",
|
||||||
Headline: "A headline",
|
Headline: "A headline",
|
||||||
|
Ends: &ends,
|
||||||
}},
|
}},
|
||||||
},
|
},
|
||||||
}, "/alerts/active")
|
}, "/alerts/active")
|
||||||
@@ -313,6 +361,160 @@ func TestAlertsUSUnitsKeepSchema(t *testing.T) {
|
|||||||
if first["id"] != "abc" {
|
if first["id"] != "abc" {
|
||||||
t.Fatalf("expected alert id abc, got %#v", first["id"])
|
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) {
|
func TestObservationUSUnitsWithXMLFormat(t *testing.T) {
|
||||||
@@ -1159,6 +1361,9 @@ func TestCurrentConditionsMetricDefaultJSON(t *testing.T) {
|
|||||||
if payload.Data["conditionText"] != "Rain" {
|
if payload.Data["conditionText"] != "Rain" {
|
||||||
t.Fatalf("expected conditionText Rain, got %#v", payload.Data["conditionText"])
|
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) {
|
func TestCurrentConditionsUSJSON(t *testing.T) {
|
||||||
@@ -1224,6 +1429,9 @@ func TestCurrentConditionsXMLAndTextFormats(t *testing.T) {
|
|||||||
if !strings.Contains(w.Body.String(), "temperatureF") {
|
if !strings.Contains(w.Body.String(), "temperatureF") {
|
||||||
t.Fatalf("expected US field temperatureF in XML payload: %s", w.Body.String())
|
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()
|
w = httptest.NewRecorder()
|
||||||
req = httptest.NewRequest(http.MethodGet, "/conditions/current?format=TEXT", nil)
|
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") {
|
if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") {
|
||||||
t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
|
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())
|
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{
|
h := newHandler(t, &fakeService{
|
||||||
alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()},
|
alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()},
|
||||||
}, "/alerts/active")
|
}, "/alerts/active")
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
for _, rawURL := range []string{
|
||||||
req := httptest.NewRequest(http.MethodGet, "/alerts/active?precision=1", nil)
|
"/alerts/active?precision=1",
|
||||||
h.ServeHTTP(w, req)
|
"/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 {
|
if w.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("expected 400, got %d", w.Code)
|
t.Fatalf("%s: expected 400, got %d", rawURL, w.Code)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1284,13 +1499,15 @@ func TestOutlookRoutesRegistered(t *testing.T) {
|
|||||||
for _, path := range []string{
|
for _, path := range []string{
|
||||||
"/outlooks/convective",
|
"/outlooks/convective",
|
||||||
"/outlooks/convective/active",
|
"/outlooks/convective/active",
|
||||||
"/outlooks/convective/location",
|
|
||||||
} {
|
} {
|
||||||
def := definitionForPath(t, defs, path)
|
def := definitionForPath(t, defs, path)
|
||||||
if len(def.Methods) != 1 || def.Methods[0] != http.MethodGet {
|
if len(def.Methods) != 1 || def.Methods[0] != http.MethodGet {
|
||||||
t.Fatalf("%s: expected GET definition, got %+v", path, def.Methods)
|
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) {
|
func TestOutlookRoutesJSONSuccess(t *testing.T) {
|
||||||
@@ -1299,7 +1516,6 @@ func TestOutlookRoutesJSONSuccess(t *testing.T) {
|
|||||||
for _, path := range []string{
|
for _, path := range []string{
|
||||||
"/outlooks/convective",
|
"/outlooks/convective",
|
||||||
"/outlooks/convective/active",
|
"/outlooks/convective/active",
|
||||||
"/outlooks/convective/location",
|
|
||||||
} {
|
} {
|
||||||
t.Run(path, func(t *testing.T) {
|
t.Run(path, func(t *testing.T) {
|
||||||
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, path)
|
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, path)
|
||||||
@@ -1476,7 +1692,7 @@ func TestOutlookQueryParamsConstructFilter(t *testing.T) {
|
|||||||
h := newHandler(t, svc, "/outlooks/convective")
|
h := newHandler(t, svc, "/outlooks/convective")
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
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)
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
@@ -1492,15 +1708,12 @@ func TestOutlookQueryParamsConstructFilter(t *testing.T) {
|
|||||||
if filter.OutlookType != "tornado" {
|
if filter.OutlookType != "tornado" {
|
||||||
t.Fatalf("expected outlookType tornado, got %q", filter.OutlookType)
|
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 {
|
if filter.ActiveAt != nil {
|
||||||
t.Fatalf("expected no active filter, got %v", filter.ActiveAt)
|
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))
|
now := time.Date(2026, 6, 11, 15, 30, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||||
setOutlookNowForTest(t, now)
|
setOutlookNowForTest(t, now)
|
||||||
|
|
||||||
@@ -1516,28 +1729,6 @@ func TestOutlookActiveAndLocationFiltersUseNow(t *testing.T) {
|
|||||||
if activeFilter.ActiveAt == nil || !activeFilter.ActiveAt.Equal(now.UTC()) {
|
if activeFilter.ActiveAt == nil || !activeFilter.ActiveAt.Equal(now.UTC()) {
|
||||||
t.Fatalf("expected activeAt %s, got %v", now.UTC(), activeFilter.ActiveAt)
|
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) {
|
func TestOutlookInvalidQueryParamsReturnBadRequest(t *testing.T) {
|
||||||
@@ -1548,10 +1739,10 @@ func TestOutlookInvalidQueryParamsReturnBadRequest(t *testing.T) {
|
|||||||
"/outlooks/convective?day=4",
|
"/outlooks/convective?day=4",
|
||||||
"/outlooks/convective?day=two",
|
"/outlooks/convective?day=two",
|
||||||
"/outlooks/convective?outlookType=snow",
|
"/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=not-a-timezone",
|
||||||
"/outlooks/convective?tz=CDT&TZ=EST",
|
"/outlooks/convective?tz=CDT&TZ=EST",
|
||||||
"/outlooks/convective/location?containsLocation=true",
|
|
||||||
} {
|
} {
|
||||||
t.Run(rawURL, func(t *testing.T) {
|
t.Run(rawURL, func(t *testing.T) {
|
||||||
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, strings.Split(rawURL, "?")[0])
|
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, strings.Split(rawURL, "?")[0])
|
||||||
@@ -2240,6 +2431,15 @@ func definitionForPath(t *testing.T, defs []endpoint.Definition, path string) en
|
|||||||
return endpoint.Definition{}
|
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 {
|
func testRenderers(t *testing.T) *render.Registry {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -2263,8 +2463,8 @@ func testRenderers(t *testing.T) *render.Registry {
|
|||||||
"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}}",
|
"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.txt.tmpl": "Weather Stories",
|
||||||
"weatherstories_latest.txt.tmpl": "Latest Weather Story",
|
"weatherstories_latest.txt.tmpl": "Latest Weather Story",
|
||||||
"alerts_active.txt.tmpl": "Alerts 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": "Conditions text",
|
"conditions_current.txt.tmpl": "Condition Code: {{.Data.ConditionCode}}",
|
||||||
} {
|
} {
|
||||||
tmpl, err := template.New(name).Parse(body)
|
tmpl, err := template.New(name).Parse(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2297,6 +2497,13 @@ func setOutlookNowForTest(t *testing.T, now time.Time) {
|
|||||||
t.Cleanup(func() { outlookNow = original })
|
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 {
|
func testOutlookRun() *model.WeatherOutlookRun {
|
||||||
issuedAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
issuedAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
||||||
discussionUpdatedAt := issuedAt.Add(30 * time.Minute)
|
discussionUpdatedAt := issuedAt.Add(30 * time.Minute)
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ type outlookFilterMode int
|
|||||||
const (
|
const (
|
||||||
outlookFilterUser outlookFilterMode = iota
|
outlookFilterUser outlookFilterMode = iota
|
||||||
outlookFilterActive
|
outlookFilterActive
|
||||||
outlookFilterLocation
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var outlookNow = time.Now
|
var outlookNow = time.Now
|
||||||
@@ -27,7 +26,6 @@ func outlookDefinitions(svc Service) []endpoint.Definition {
|
|||||||
return []endpoint.Definition{
|
return []endpoint.Definition{
|
||||||
outlookDefinition("/outlooks/convective", outlookFilterUser, bindOutlookQuery, svc),
|
outlookDefinition("/outlooks/convective", outlookFilterUser, bindOutlookQuery, svc),
|
||||||
outlookDefinition("/outlooks/convective/active", outlookFilterActive, bindOutlookQuery, svc),
|
outlookDefinition("/outlooks/convective/active", outlookFilterActive, bindOutlookQuery, svc),
|
||||||
outlookDefinition("/outlooks/convective/location", outlookFilterLocation, bindOutlookLocationQuery, svc),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,14 +40,10 @@ func outlookDefinition(
|
|||||||
binder,
|
binder,
|
||||||
func(ctx context.Context, req outlookQueryRequest) (any, error) {
|
func(ctx context.Context, req outlookQueryRequest) (any, error) {
|
||||||
filter := req.Filter
|
filter := req.Filter
|
||||||
if mode == outlookFilterActive || mode == outlookFilterLocation {
|
if mode == outlookFilterActive {
|
||||||
activeAt := outlookNow().UTC()
|
activeAt := outlookNow().UTC()
|
||||||
filter.ActiveAt = &activeAt
|
filter.ActiveAt = &activeAt
|
||||||
}
|
}
|
||||||
if mode == outlookFilterLocation {
|
|
||||||
containsLocation := true
|
|
||||||
filter.ContainsLocation = &containsLocation
|
|
||||||
}
|
|
||||||
|
|
||||||
run, err := svc.LatestConvectiveOutlook(ctx, filter)
|
run, err := svc.LatestConvectiveOutlook(ctx, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,25 +4,27 @@ package presenter
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
|
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CurrentConditionsResponse is the response shape for /conditions/current.
|
// CurrentConditionsResponse is the response shape for /conditions/current.
|
||||||
// Unit-bearing fields are populated according to the requested unit mode.
|
// Unit-bearing fields are populated according to the requested unit mode.
|
||||||
type CurrentConditionsResponse struct {
|
type CurrentConditionsResponse struct {
|
||||||
TemperatureC *float64 `json:"temperatureC,omitempty" xml:"temperatureC,omitempty"`
|
TemperatureC *float64 `json:"temperatureC,omitempty" xml:"temperatureC,omitempty"`
|
||||||
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty" xml:"apparentTemperatureC,omitempty"`
|
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty" xml:"apparentTemperatureC,omitempty"`
|
||||||
DewpointC *float64 `json:"dewpointC,omitempty" xml:"dewpointC,omitempty"`
|
DewpointC *float64 `json:"dewpointC,omitempty" xml:"dewpointC,omitempty"`
|
||||||
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty" xml:"windSpeedKmh,omitempty"`
|
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty" xml:"windSpeedKmh,omitempty"`
|
||||||
TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"`
|
TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"`
|
||||||
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"`
|
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"`
|
||||||
DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"`
|
DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"`
|
||||||
WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"`
|
WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"`
|
||||||
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"`
|
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"`
|
||||||
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"`
|
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"`
|
||||||
ConditionText string `json:"conditionText,omitempty" xml:"conditionText,omitempty"`
|
ConditionCode model.WMOCode `json:"conditionCode" xml:"conditionCode"`
|
||||||
IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"`
|
ConditionText string `json:"conditionText,omitempty" xml:"conditionText,omitempty"`
|
||||||
IsDayText string `json:"-" xml:"-"`
|
IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"`
|
||||||
|
IsDayText string `json:"-" xml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func CurrentConditionsPayload(conditions *app.CurrentConditions, units Units, precision int) any {
|
func CurrentConditionsPayload(conditions *app.CurrentConditions, units Units, precision int) any {
|
||||||
@@ -33,6 +35,7 @@ func CurrentConditionsPayload(conditions *app.CurrentConditions, units Units, pr
|
|||||||
out := CurrentConditionsResponse{
|
out := CurrentConditionsResponse{
|
||||||
RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(conditions.RelativeHumidityPercent), precision),
|
RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(conditions.RelativeHumidityPercent), precision),
|
||||||
WindDirectionDegrees: roundedPtr(copyFloat64Ptr(conditions.WindDirectionDegrees), precision),
|
WindDirectionDegrees: roundedPtr(copyFloat64Ptr(conditions.WindDirectionDegrees), precision),
|
||||||
|
ConditionCode: conditions.ConditionCode,
|
||||||
ConditionText: standards.WMOText(conditions.ConditionCode, conditions.IsDay),
|
ConditionText: standards.WMOText(conditions.ConditionCode, conditions.IsDay),
|
||||||
IsDay: copyBoolPtr(conditions.IsDay),
|
IsDay: copyBoolPtr(conditions.IsDay),
|
||||||
IsDayText: boolText(conditions.IsDay),
|
IsDayText: boolText(conditions.IsDay),
|
||||||
|
|||||||
@@ -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) {
|
func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
|
||||||
conditions := &app.CurrentConditions{
|
conditions := &app.CurrentConditions{
|
||||||
TemperatureC: float64Ptr(20),
|
TemperatureC: float64Ptr(20),
|
||||||
@@ -362,6 +378,9 @@ func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
|
|||||||
if metric.ConditionText != "Sunny" {
|
if metric.ConditionText != "Sunny" {
|
||||||
t.Fatalf("expected condition text Sunny, got %q", metric.ConditionText)
|
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)
|
usPayload := CurrentConditionsPayload(conditions, UnitsUS, 2)
|
||||||
us, ok := usPayload.(CurrentConditionsResponse)
|
us, ok := usPayload.(CurrentConditionsResponse)
|
||||||
@@ -373,6 +392,9 @@ func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
|
|||||||
if us.TemperatureC != nil || us.WindSpeedKmh != nil {
|
if us.TemperatureC != nil || us.WindSpeedKmh != nil {
|
||||||
t.Fatalf("expected metric fields omitted for US payload")
|
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) {
|
func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) {
|
||||||
|
|||||||
@@ -145,29 +145,16 @@ func bindPrecisionQueryInternal(r *http.Request, allowTimezone bool) (precisionQ
|
|||||||
}
|
}
|
||||||
|
|
||||||
func bindOutlookQuery(r *http.Request) (outlookQueryRequest, error) {
|
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, "units")
|
||||||
normalizeCommonQueryValue(r, "format")
|
normalizeCommonQueryValue(r, "format")
|
||||||
normalizeCommonQueryValue(r, "outlookType")
|
normalizeCommonQueryValue(r, "outlookType")
|
||||||
|
|
||||||
allowedExtra := []string{"tz", "TZ", "day", "outlookType"}
|
|
||||||
if allowContainsLocation {
|
|
||||||
allowedExtra = append(allowedExtra, "containsLocation")
|
|
||||||
}
|
|
||||||
|
|
||||||
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
|
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
|
||||||
AllowUnits: true,
|
AllowUnits: true,
|
||||||
AllowFormat: true,
|
AllowFormat: true,
|
||||||
DefaultUnits: string(presenter.UnitsMetric),
|
DefaultUnits: string(presenter.UnitsMetric),
|
||||||
RejectUnknown: true,
|
RejectUnknown: true,
|
||||||
}, allowedExtra...)
|
}, "tz", "TZ", "day", "outlookType")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return outlookQueryRequest{}, err
|
return outlookQueryRequest{}, err
|
||||||
}
|
}
|
||||||
@@ -182,7 +169,7 @@ func bindOutlookQueryInternal(r *http.Request, allowContainsLocation bool) (outl
|
|||||||
return outlookQueryRequest{}, err
|
return outlookQueryRequest{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
filter, err := bindOutlookFilter(r, allowContainsLocation)
|
filter, err := bindOutlookFilter(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return outlookQueryRequest{}, err
|
return outlookQueryRequest{}, err
|
||||||
}
|
}
|
||||||
@@ -194,7 +181,7 @@ func bindOutlookQueryInternal(r *http.Request, allowContainsLocation bool) (outl
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func bindOutlookFilter(r *http.Request, allowContainsLocation bool) (app.OutlookFilter, error) {
|
func bindOutlookFilter(r *http.Request) (app.OutlookFilter, error) {
|
||||||
var filter app.OutlookFilter
|
var filter app.OutlookFilter
|
||||||
|
|
||||||
if strings.TrimSpace(r.URL.Query().Get("day")) != "" {
|
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
|
return filter, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
|
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
|
||||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
@@ -18,6 +19,7 @@ type Service interface {
|
|||||||
LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error)
|
LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error)
|
||||||
LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error)
|
LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error)
|
||||||
LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, 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)
|
LatestConvectiveOutlook(ctx context.Context, filter app.OutlookFilter) (*model.WeatherOutlookRun, error)
|
||||||
CurrentConditions(ctx context.Context) (*app.CurrentConditions, error)
|
CurrentConditions(ctx context.Context) (*app.CurrentConditions, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ func mapAlertRow(row alertRow) indexedAlert {
|
|||||||
Sent: timePtr(row.Sent),
|
Sent: timePtr(row.Sent),
|
||||||
Effective: timePtr(row.Effective),
|
Effective: timePtr(row.Effective),
|
||||||
Onset: timePtr(row.Onset),
|
Onset: timePtr(row.Onset),
|
||||||
|
Ends: timePtr(row.Ends),
|
||||||
Expires: timePtr(row.Expires),
|
Expires: timePtr(row.Expires),
|
||||||
AreaDescription: stringValue(row.AreaDescription),
|
AreaDescription: stringValue(row.AreaDescription),
|
||||||
SenderName: stringValue(row.SenderName),
|
SenderName: stringValue(row.SenderName),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ SELECT
|
|||||||
sent,
|
sent,
|
||||||
effective,
|
effective,
|
||||||
onset,
|
onset,
|
||||||
|
ends,
|
||||||
expires,
|
expires,
|
||||||
area_description,
|
area_description,
|
||||||
sender_name
|
sender_name
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ func (r *Repository) loadAlerts(ctx context.Context, eventID string) ([]model.We
|
|||||||
&row.Sent,
|
&row.Sent,
|
||||||
&row.Effective,
|
&row.Effective,
|
||||||
&row.Onset,
|
&row.Onset,
|
||||||
|
&row.Ends,
|
||||||
&row.Expires,
|
&row.Expires,
|
||||||
&row.AreaDescription,
|
&row.AreaDescription,
|
||||||
&row.SenderName,
|
&row.SenderName,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type alertRow struct {
|
|||||||
Sent sql.NullTime
|
Sent sql.NullTime
|
||||||
Effective sql.NullTime
|
Effective sql.NullTime
|
||||||
Onset sql.NullTime
|
Onset sql.NullTime
|
||||||
|
Ends sql.NullTime
|
||||||
Expires sql.NullTime
|
Expires sql.NullTime
|
||||||
AreaDescription sql.NullString
|
AreaDescription sql.NullString
|
||||||
SenderName sql.NullString
|
SenderName sql.NullString
|
||||||
|
|||||||
107
internal/adapters/outbound/postgres/conditions_codes.go
Normal file
107
internal/adapters/outbound/postgres/conditions_codes.go
Normal 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
|
||||||
|
}
|
||||||
116
internal/adapters/outbound/postgres/conditions_codes_test.go
Normal file
116
internal/adapters/outbound/postgres/conditions_codes_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -7,16 +7,11 @@ import (
|
|||||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
"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 {
|
if row.SampleCount == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
conditionCode := model.WMOUnknown
|
|
||||||
if row.ConditionCode.Valid {
|
|
||||||
conditionCode = model.WMOCode(row.ConditionCode.Int64)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &app.CurrentConditions{
|
return &app.CurrentConditions{
|
||||||
TemperatureC: float64Ptr(row.TemperatureC),
|
TemperatureC: float64Ptr(row.TemperatureC),
|
||||||
ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC),
|
ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC),
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ WITH windowed AS (
|
|||||||
relative_humidity_percent,
|
relative_humidity_percent,
|
||||||
wind_speed_kmh,
|
wind_speed_kmh,
|
||||||
wind_direction_degrees,
|
wind_direction_degrees,
|
||||||
condition_code,
|
|
||||||
is_day,
|
is_day,
|
||||||
observed_at
|
observed_at
|
||||||
FROM observations
|
FROM observations
|
||||||
@@ -39,7 +38,6 @@ SELECT
|
|||||||
AVG(cosd(wind_direction_degrees))
|
AVG(cosd(wind_direction_degrees))
|
||||||
)
|
)
|
||||||
END AS wind_direction_degrees,
|
END AS wind_direction_degrees,
|
||||||
MAX(condition_code) AS condition_code,
|
|
||||||
(
|
(
|
||||||
SELECT is_day
|
SELECT is_day
|
||||||
FROM windowed
|
FROM windowed
|
||||||
@@ -47,4 +45,23 @@ SELECT
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
) AS is_day
|
) AS is_day
|
||||||
FROM windowed`
|
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`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
|
"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) {
|
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.RelativeHumidityPercent,
|
||||||
&row.WindSpeedKmh,
|
&row.WindSpeedKmh,
|
||||||
&row.WindDirectionDegrees,
|
&row.WindDirectionDegrees,
|
||||||
&row.ConditionCode,
|
|
||||||
&row.IsDay,
|
&row.IsDay,
|
||||||
)
|
)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
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 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
|
||||||
}
|
}
|
||||||
|
|||||||
280
internal/adapters/outbound/postgres/conditions_read_test.go
Normal file
280
internal/adapters/outbound/postgres/conditions_read_test.go
Normal 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 ¤tConditionsTestRows{
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -12,6 +12,5 @@ type currentConditionsRow struct {
|
|||||||
RelativeHumidityPercent sql.NullFloat64
|
RelativeHumidityPercent sql.NullFloat64
|
||||||
WindSpeedKmh sql.NullFloat64
|
WindSpeedKmh sql.NullFloat64
|
||||||
WindDirectionDegrees sql.NullFloat64
|
WindDirectionDegrees sql.NullFloat64
|
||||||
ConditionCode sql.NullInt64
|
|
||||||
IsDay sql.NullBool
|
IsDay sql.NullBool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestMapCurrentConditionsRowNoSamplesReturnsNil(t *testing.T) {
|
||||||
got := mapCurrentConditionsRow(currentConditionsRow{
|
got := mapCurrentConditionsRow(currentConditionsRow{
|
||||||
SampleCount: 0,
|
SampleCount: 0,
|
||||||
})
|
}, model.WMOUnknown)
|
||||||
if got != nil {
|
if got != nil {
|
||||||
t.Fatalf("expected nil for empty sample window, got %+v", got)
|
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},
|
RelativeHumidityPercent: sql.NullFloat64{Float64: 72, Valid: true},
|
||||||
WindSpeedKmh: sql.NullFloat64{Float64: 24.8, Valid: true},
|
WindSpeedKmh: sql.NullFloat64{Float64: 24.8, Valid: true},
|
||||||
WindDirectionDegrees: sql.NullFloat64{Float64: 182.5, Valid: true},
|
WindDirectionDegrees: sql.NullFloat64{Float64: 182.5, Valid: true},
|
||||||
ConditionCode: sql.NullInt64{Int64: 65, Valid: true},
|
|
||||||
IsDay: sql.NullBool{Bool: isDay, Valid: true},
|
IsDay: sql.NullBool{Bool: isDay, Valid: true},
|
||||||
})
|
}, 65)
|
||||||
if got == nil {
|
if got == nil {
|
||||||
t.Fatalf("expected mapped current conditions")
|
t.Fatalf("expected mapped current conditions")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const nwsAlertURLPrefix = "https://api.weather.gov/alerts/"
|
||||||
|
|
||||||
// Repository defines outbound data access used by weatherapi use cases.
|
// Repository defines outbound data access used by weatherapi use cases.
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
|
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
|
||||||
@@ -25,10 +27,9 @@ type Repository interface {
|
|||||||
|
|
||||||
// OutlookFilter selects outlook entries from the latest convective outlook run.
|
// OutlookFilter selects outlook entries from the latest convective outlook run.
|
||||||
type OutlookFilter struct {
|
type OutlookFilter struct {
|
||||||
Day *int
|
Day *int
|
||||||
OutlookType string
|
OutlookType string
|
||||||
ContainsLocation *bool
|
ActiveAt *time.Time
|
||||||
ActiveAt *time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service provides weather read use-cases.
|
// 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)
|
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) {
|
func (s *Service) LatestConvectiveOutlook(ctx context.Context, filter OutlookFilter) (*model.WeatherOutlookRun, error) {
|
||||||
run, err := s.repo.LatestConvectiveOutlookRun(ctx)
|
run, err := s.repo.LatestConvectiveOutlookRun(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -100,9 +122,6 @@ func matchesOutlookFilter(outlook model.WeatherOutlook, filter OutlookFilter) bo
|
|||||||
if filter.OutlookType != "" && outlook.OutlookType != normalizeOutlookType(filter.OutlookType) {
|
if filter.OutlookType != "" && outlook.OutlookType != normalizeOutlookType(filter.OutlookType) {
|
||||||
return false
|
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)) {
|
if filter.ActiveAt != nil && (filter.ActiveAt.Before(outlook.ValidFrom) || !filter.ActiveAt.Before(outlook.ValidTo)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -113,6 +132,93 @@ func normalizeOutlookType(value string) string {
|
|||||||
return strings.ToLower(strings.TrimSpace(value))
|
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 {
|
func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
|
||||||
out := *run
|
out := *run
|
||||||
out.Latitude = copyFloat64(run.Latitude)
|
out.Latitude = copyFloat64(run.Latitude)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type fakeRepository struct {
|
|||||||
err error
|
err error
|
||||||
|
|
||||||
currentConditionsWindow int
|
currentConditionsWindow int
|
||||||
|
alertRunCalls int
|
||||||
outlookRunCalls int
|
outlookRunCalls int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ func (r *fakeRepository) LatestWeatherStory(context.Context) (*model.WeatherStor
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
|
func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
|
||||||
|
r.alertRunCalls++
|
||||||
return r.alerts, r.err
|
return r.alerts, r.err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,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) {
|
func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
|
||||||
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
||||||
svc := NewService(repo)
|
svc := NewService(repo)
|
||||||
@@ -225,32 +433,6 @@ func TestServiceLatestConvectiveOutlookFiltersByOutlookType(t *testing.T) {
|
|||||||
assertDiscussionDays(t, run, []int{1})
|
assertDiscussionDays(t, run, []int{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", "day-2"})
|
|
||||||
assertDiscussionDays(t, run, []int{1, 2})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceLatestConvectiveOutlookContainsLocationFalseReturnsEmptyRun(t *testing.T) {
|
|
||||||
containsLocation := false
|
|
||||||
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, nil)
|
|
||||||
assertDiscussionDays(t, run, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceLatestConvectiveOutlookFiltersByActiveAt(t *testing.T) {
|
func TestServiceLatestConvectiveOutlookFiltersByActiveAt(t *testing.T) {
|
||||||
activeAt := time.Date(2026, 6, 11, 15, 0, 0, 0, time.UTC)
|
activeAt := time.Date(2026, 6, 11, 15, 0, 0, 0, time.UTC)
|
||||||
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
||||||
@@ -266,16 +448,14 @@ func TestServiceLatestConvectiveOutlookFiltersByActiveAt(t *testing.T) {
|
|||||||
|
|
||||||
func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) {
|
func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) {
|
||||||
day := 1
|
day := 1
|
||||||
containsLocation := true
|
|
||||||
activeAt := time.Date(2026, 6, 11, 15, 0, 0, 0, time.UTC)
|
activeAt := time.Date(2026, 6, 11, 15, 0, 0, 0, time.UTC)
|
||||||
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
||||||
svc := NewService(repo)
|
svc := NewService(repo)
|
||||||
|
|
||||||
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{
|
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{
|
||||||
Day: &day,
|
Day: &day,
|
||||||
OutlookType: "categorical",
|
OutlookType: "categorical",
|
||||||
ContainsLocation: &containsLocation,
|
ActiveAt: &activeAt,
|
||||||
ActiveAt: &activeAt,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -402,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 {
|
func testOutlookRun() *model.WeatherOutlookRun {
|
||||||
latitude := 38.62
|
latitude := 38.62
|
||||||
longitude := -90.2
|
longitude := -90.2
|
||||||
@@ -493,3 +737,18 @@ func assertDiscussionDays(t *testing.T, run *model.WeatherOutlookRun, want []int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ Headline: {{$alert.Headline}}
|
|||||||
{{- if $alert.Severity}}
|
{{- if $alert.Severity}}
|
||||||
Severity: {{$alert.Severity}}
|
Severity: {{$alert.Severity}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
{{- if $alert.Ends}}
|
||||||
|
Ends: {{$alert.Ends}}
|
||||||
|
{{- end}}
|
||||||
{{- if $alert.Expires}}
|
{{- if $alert.Expires}}
|
||||||
Expires: {{$alert.Expires}}
|
Expires: {{$alert.Expires}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{{- if .Data -}}
|
{{- if .Data -}}
|
||||||
Current Conditions
|
Current Conditions
|
||||||
|
Condition Code: {{.Data.ConditionCode}}
|
||||||
{{- if .Data.ConditionText}}
|
{{- if .Data.ConditionText}}
|
||||||
Condition: {{.Data.ConditionText}}
|
Condition: {{.Data.ConditionText}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user