Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 370d7c0928 | |||
| b556052966 | |||
| c7760742e6 | |||
| beeaeeaecb | |||
| 1f2459923f | |||
| fa1b2cb390 | |||
| 82dd304f10 | |||
| 14c6954296 | |||
| 0135eb1153 |
67
docs/api.md
67
docs/api.md
@@ -230,19 +230,22 @@ GET /outlooks/convective/location
|
|||||||
```
|
```
|
||||||
|
|
||||||
Returns the latest SPC convective outlook run reconstructed from
|
Returns the latest SPC convective outlook run reconstructed from
|
||||||
weatherfeeder-owned Postgres tables.
|
weatherfeeder-owned `weather.outlook.v2` Postgres tables.
|
||||||
|
|
||||||
Route behavior:
|
Route behavior:
|
||||||
|
|
||||||
- `/outlooks/convective` returns the latest run with all stored outlook
|
- `/outlooks/convective` returns the latest run with stored location-filtered
|
||||||
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
|
- `/outlooks/convective/location` adds the same active-time filter and
|
||||||
`containsLocation=true`.
|
`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` is an empty array.
|
no outlooks, `data` remains an object and `outlooks` and `discussions` are
|
||||||
|
empty arrays. Outlook endpoints use latest-run semantics and do not accumulate
|
||||||
|
historical active outlooks across older runs.
|
||||||
|
|
||||||
Query parameters:
|
Query parameters:
|
||||||
|
|
||||||
@@ -253,9 +256,12 @@ Query parameters:
|
|||||||
| `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 |
|
| `containsLocation` | `/outlooks/convective`, `/outlooks/convective/active` | boolean |
|
||||||
|
|
||||||
`outlookType` values are normalized case-insensitively. `containsLocation` is
|
`outlookType` values are normalized case-insensitively. Weatherfeeder v2
|
||||||
rejected on `/outlooks/convective/location` because that route always applies
|
outlooks are already filtered for the configured location, so
|
||||||
`containsLocation=true`. `precision` and unknown parameters are rejected.
|
`containsLocation` is expected to be true for stored v2 outlooks.
|
||||||
|
`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:
|
||||||
|
|
||||||
@@ -266,6 +272,7 @@ Run `data` fields:
|
|||||||
| `asOf` | RFC3339 datetime | required when `data` is not null |
|
| `asOf` | RFC3339 datetime | required when `data` is not null |
|
||||||
| `issuedAt` | RFC3339 datetime | optional |
|
| `issuedAt` | RFC3339 datetime | optional |
|
||||||
| `outlooks` | array | ordered outlook polygons, possibly empty |
|
| `outlooks` | array | ordered outlook polygons, possibly empty |
|
||||||
|
| `discussions` | array | ordered day-level discussions, possibly empty |
|
||||||
|
|
||||||
Outlook fields:
|
Outlook fields:
|
||||||
|
|
||||||
@@ -273,17 +280,27 @@ Outlook fields:
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id`, `provider`, `product`, `outlookType`, `label` | string | required when an outlook is present |
|
| `id`, `provider`, `product`, `outlookType`, `label` | string | required when an outlook is present |
|
||||||
| `day` | integer | SPC outlook day |
|
| `day` | integer | SPC outlook day |
|
||||||
| `labelText`, `forecaster`, `headline`, `summary`, `discussion` | string | optional |
|
| `labelText`, `forecaster` | string | optional |
|
||||||
| `severityRank` | integer | optional |
|
| `severityRank` | integer | optional |
|
||||||
| `validFrom`, `validTo`, `issuedAt`, `expiresAt` | RFC3339 datetime | required when an outlook is present |
|
| `validFrom`, `validTo`, `issuedAt`, `expiresAt` | RFC3339 datetime | required when an outlook is present |
|
||||||
| `sourceUrl`, `imageUrl` | string | optional |
|
| `sourceUrl`, `imageUrl` | string | optional |
|
||||||
| `containsLocation` | boolean | whether the outlook polygon contains the configured location |
|
| `containsLocation` | boolean | whether the outlook polygon contains the configured location |
|
||||||
| `geometry` | GeoJSON | stored outlook geometry |
|
| `geometry` | GeoJSON | stored outlook geometry |
|
||||||
|
|
||||||
|
Discussion fields:
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `day` | integer | SPC outlook day |
|
||||||
|
| `headline`, `summary`, `discussion` | string | optional |
|
||||||
|
| `updatedAt` | RFC3339 datetime | optional |
|
||||||
|
|
||||||
GeoJSON coordinates use standard GeoJSON coordinate order: longitude, then
|
GeoJSON coordinates use standard GeoJSON coordinate order: longitude, then
|
||||||
latitude. Timezone conversion applies to run `asOf`, run `issuedAt`, and each
|
latitude. Timezone conversion applies to run `asOf`, run `issuedAt`, and each
|
||||||
outlook's `validFrom`, `validTo`, `issuedAt`, and `expiresAt`. Active filtering
|
outlook's `validFrom`, `validTo`, `issuedAt`, and `expiresAt`, and discussion
|
||||||
compares instants and is not changed by the presentation timezone.
|
`updatedAt`. Active filtering compares instants and is not changed by the
|
||||||
|
presentation timezone. Endpoint filters also filter `discussions` to days
|
||||||
|
represented by retained outlooks.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
@@ -328,6 +345,15 @@ Example JSON response:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"discussions": [
|
||||||
|
{
|
||||||
|
"day": 1,
|
||||||
|
"headline": "Severe storms possible",
|
||||||
|
"summary": "Scattered severe storms are possible.",
|
||||||
|
"discussion": "SPC discussion text.",
|
||||||
|
"updatedAt": "2026-06-11T17:30:00Z"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,10 +380,25 @@ Example location-filtered JSON response:
|
|||||||
"expiresAt": "2026-06-12T12:00:00Z",
|
"expiresAt": "2026-06-12T12:00:00Z",
|
||||||
"containsLocation": true,
|
"containsLocation": true,
|
||||||
"geometry": {
|
"geometry": {
|
||||||
"type": "Point",
|
"type": "Polygon",
|
||||||
"coordinates": [-90.2, 38.6]
|
"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"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,12 @@
|
|||||||
|
|
||||||
`go.mod` depends on:
|
`go.mod` depends on:
|
||||||
|
|
||||||
- `gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0`
|
- `gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0`
|
||||||
|
|
||||||
The repository code also depends on weatherfeeder canonical model types. Table
|
The repository code also depends on weatherfeeder canonical model types. Table
|
||||||
compatibility must match the SQL in `internal/adapters/outbound/postgres`.
|
compatibility must match the SQL in `internal/adapters/outbound/postgres`.
|
||||||
|
For convective outlooks, `weatherapi` assumes weatherfeeder's
|
||||||
|
`weather.outlook.v2` table reset has already been applied.
|
||||||
|
|
||||||
## Boundary
|
## Boundary
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ Postgres owns persistence, backup, restore, and availability.
|
|||||||
| Forecast discussion | `forecast_discussions`, `forecast_discussion_key_messages` |
|
| Forecast discussion | `forecast_discussions`, `forecast_discussion_key_messages` |
|
||||||
| Weather story run | `weather_story_runs`, `weather_stories` |
|
| Weather story run | `weather_story_runs`, `weather_stories` |
|
||||||
| Latest weather story | `weather_stories` |
|
| Latest weather story | `weather_stories` |
|
||||||
| Convective outlook run | `outlook_runs`, `outlooks` |
|
| Convective outlook run | `outlook_runs`, `outlooks`, `outlook_discussions` |
|
||||||
|
|
||||||
## Latest Row Selection
|
## Latest Row Selection
|
||||||
|
|
||||||
@@ -68,7 +70,8 @@ Child rows are loaded separately and attached in stored order:
|
|||||||
- forecast periods: `period_index ASC`;
|
- forecast periods: `period_index ASC`;
|
||||||
- forecast discussion key messages: `message_index ASC`;
|
- forecast discussion key messages: `message_index ASC`;
|
||||||
- weather stories for a run: `story_index ASC`;
|
- weather stories for a run: `story_index ASC`;
|
||||||
- outlooks for a run: `outlook_index ASC`.
|
- outlooks for a run: `outlook_index ASC`;
|
||||||
|
- outlook discussions for a run: `discussion_index ASC`.
|
||||||
|
|
||||||
## Columns Read
|
## Columns Read
|
||||||
|
|
||||||
@@ -160,12 +163,17 @@ routes.
|
|||||||
|
|
||||||
`outlook_index`, `outlook_id`, `provider`, `product`, `day`, `outlook_type`,
|
`outlook_index`, `outlook_id`, `provider`, `product`, `day`, `outlook_type`,
|
||||||
`label`, `label_text`, `severity_rank`, `valid_from`, `valid_to`, `issued_at`,
|
`label`, `label_text`, `severity_rank`, `valid_from`, `valid_to`, `issued_at`,
|
||||||
`expires_at`, `forecaster`, `headline`, `summary`, `discussion`, `source_url`,
|
`expires_at`, `forecaster`, `source_url`, `image_url`, `contains_location`,
|
||||||
`image_url`, `contains_location`, `geometry_json`, and `run_event_id`.
|
`geometry_json`, and `run_event_id`.
|
||||||
|
|
||||||
`geometry_json` is copied into response GeoJSON without parsing or
|
`geometry_json` is copied into response GeoJSON without parsing or
|
||||||
reserializing. It must contain valid JSON.
|
reserializing. It must contain valid JSON.
|
||||||
|
|
||||||
|
### `outlook_discussions`
|
||||||
|
|
||||||
|
`discussion_index`, `day`, `headline`, `summary`, `discussion`, `updated_at`,
|
||||||
|
and `run_event_id`.
|
||||||
|
|
||||||
## Nullability and Time Assumptions
|
## Nullability and Time Assumptions
|
||||||
|
|
||||||
The repository scans nullable columns with `sql.Null*` types and maps them to
|
The repository scans nullable columns with `sql.Null*` types and maps them to
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ application service:
|
|||||||
The package variable `outlookNow` exists so endpoint tests can make active and
|
The package variable `outlookNow` exists so endpoint tests can make active and
|
||||||
location filtering deterministic.
|
location filtering deterministic.
|
||||||
|
|
||||||
|
The application service returns filtered outlook copies and trims run-level
|
||||||
|
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
|
||||||
|
|
||||||
Binder failures become feedapi invalid-parameter responses. Handler service
|
Binder failures become feedapi invalid-parameter responses. Handler service
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ successful responses with `data: null`.
|
|||||||
`weather_stories`.
|
`weather_stories`.
|
||||||
- `LatestWeatherStory`: latest individual row from `weather_stories`.
|
- `LatestWeatherStory`: latest individual row from `weather_stories`.
|
||||||
- `LatestConvectiveOutlookRun`: latest row from `outlook_runs`, then child
|
- `LatestConvectiveOutlookRun`: latest row from `outlook_runs`, then child
|
||||||
`outlooks`.
|
`outlooks` and `outlook_discussions`.
|
||||||
|
|
||||||
Latest parent rows are selected by descending weather timestamp and
|
Latest parent rows are selected by descending weather timestamp and
|
||||||
`event_emitted_at` where that tie-breaker is available in the query.
|
`event_emitted_at` where that tie-breaker is available in the query.
|
||||||
@@ -105,7 +105,8 @@ Child queries preserve stored order:
|
|||||||
- forecast periods by `period_index`;
|
- forecast periods by `period_index`;
|
||||||
- discussion key messages by `message_index`;
|
- discussion key messages by `message_index`;
|
||||||
- weather stories by `story_index`;
|
- weather stories by `story_index`;
|
||||||
- outlooks by `outlook_index`.
|
- outlooks by `outlook_index`;
|
||||||
|
- outlook discussions by `discussion_index`.
|
||||||
|
|
||||||
Alert references are attached after both alert and reference rows are loaded.
|
Alert references are attached after both alert and reference rows are loaded.
|
||||||
References are grouped by alert index and attached to their corresponding alert.
|
References are grouped by alert index and attached to their corresponding alert.
|
||||||
|
|||||||
@@ -118,9 +118,11 @@ response envelope so renderers can produce `data: null`.
|
|||||||
- Discussions: full or focused payload shapes, section copy, key-message copy,
|
- Discussions: full or focused payload shapes, section copy, key-message copy,
|
||||||
timezone conversion.
|
timezone conversion.
|
||||||
- Weather stories: run/story copy and timezone conversion.
|
- Weather stories: run/story copy and timezone conversion.
|
||||||
- Convective outlooks: canonical model copy, pointer and geometry copy, and
|
- Convective outlooks: canonical model copy, pointer and geometry copy,
|
||||||
timezone conversion. `units` is accepted by routes but ignored by the
|
run-level discussion copy, and timezone conversion for run, outlook, and
|
||||||
presenter because outlook fields are not unit-bearing.
|
discussion timestamps. Outlook polygon prose is not handled by the presenter;
|
||||||
|
prose is carried by run-level discussions. `units` is accepted by routes but
|
||||||
|
ignored by the presenter because outlook fields are not unit-bearing.
|
||||||
|
|
||||||
## Templates
|
## Templates
|
||||||
|
|
||||||
|
|||||||
@@ -91,8 +91,13 @@ build.
|
|||||||
## Database Dependency
|
## Database Dependency
|
||||||
|
|
||||||
`weatherapi` expects weatherfeeder-compatible tables for observations, current
|
`weatherapi` expects weatherfeeder-compatible tables for observations, current
|
||||||
conditions aggregation, active alerts, forecasts, forecast discussions, and
|
conditions aggregation, active alerts, forecasts, forecast discussions, weather
|
||||||
weather stories. It only reads those tables.
|
stories, and convective outlooks. It only reads those tables.
|
||||||
|
|
||||||
|
Convective outlook endpoints require weatherfeeder's `weather.outlook.v2` table
|
||||||
|
shape, including `outlook_runs`, `outlooks`, and `outlook_discussions`. If
|
||||||
|
operators reset or recreate outlook tables during a weatherfeeder upgrade,
|
||||||
|
complete that weatherfeeder-side migration before starting `weatherapi`.
|
||||||
|
|
||||||
Operational ownership is split:
|
Operational ownership is split:
|
||||||
|
|
||||||
@@ -113,7 +118,7 @@ template named by each endpoint, with templates stored under `templates.base_dir
|
|||||||
|
|
||||||
The repository includes templates for all implemented endpoint families:
|
The repository includes templates for all implemented endpoint families:
|
||||||
observations, current conditions, active alerts, hourly forecasts, narrative
|
observations, current conditions, active alerts, hourly forecasts, narrative
|
||||||
forecasts, forecast discussions, and weather stories.
|
forecasts, forecast discussions, weather stories, and convective outlooks.
|
||||||
|
|
||||||
If text rendering fails or returns an unsupported-format error, verify:
|
If text rendering fails or returns an unsupported-format error, verify:
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,524 @@
|
|||||||
# SPC Convective Outlook API Roadmap
|
# Implement Weatherfeeder Outlook V2 Support
|
||||||
|
|
||||||
There are no active roadmap items for the SPC convective outlook API.
|
## Status
|
||||||
|
|
||||||
Current behavior is documented in:
|
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/`.
|
||||||
|
|
||||||
- [`docs/api.md`](../api.md)
|
## Verification Record
|
||||||
- [`docs/internal/http-adapter.md`](../internal/http-adapter.md)
|
|
||||||
- [`docs/internal/presenters.md`](../internal/presenters.md)
|
|
||||||
- [`docs/internal/postgres-repository.md`](../internal/postgres-repository.md)
|
|
||||||
- [`docs/integrations/weatherfeeder-postgres.md`](../integrations/weatherfeeder-postgres.md)
|
|
||||||
|
|
||||||
Future changes to convective outlook behavior should be proposed in a new
|
Completed on 2026-06-12:
|
||||||
roadmap entry before implementation.
|
|
||||||
|
- `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,14 +1,36 @@
|
|||||||
# SPC Convective Outlook API Roadmap
|
# Weatherfeeder Outlook V2 Support
|
||||||
|
|
||||||
There are no active roadmap items for the SPC convective outlook API.
|
## Status
|
||||||
|
|
||||||
Current behavior is documented in:
|
Implemented in current behavior docs and code. The authoritative implemented
|
||||||
|
contracts now live in:
|
||||||
|
|
||||||
- [`docs/api.md`](../api.md)
|
- [`docs/api.md`](../api.md) for public outlook routes, query parameters, and
|
||||||
- [`docs/internal/http-adapter.md`](../internal/http-adapter.md)
|
response fields;
|
||||||
- [`docs/internal/presenters.md`](../internal/presenters.md)
|
|
||||||
- [`docs/internal/postgres-repository.md`](../internal/postgres-repository.md)
|
|
||||||
- [`docs/integrations/weatherfeeder-postgres.md`](../integrations/weatherfeeder-postgres.md)
|
- [`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.
|
||||||
|
|
||||||
Future changes to convective outlook behavior should be proposed in a new
|
`docs/roadmap/implementation.md` remains as the implementation checklist and
|
||||||
roadmap entry before implementation.
|
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.
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ Likely causes:
|
|||||||
|
|
||||||
- unknown query parameter;
|
- unknown query parameter;
|
||||||
- `precision` outside `0` through `2`, or not an integer;
|
- `precision` outside `0` through `2`, or not an integer;
|
||||||
- `precision` used on alerts, discussions, or weather stories;
|
- `precision` used on alerts, discussions, weather stories, or outlooks;
|
||||||
- `tz` used on observations, current conditions, or alerts;
|
- `tz` used on observations, current conditions, or alerts;
|
||||||
- invalid timezone value;
|
- invalid timezone value;
|
||||||
- both `tz` and `TZ` are present with different values.
|
- both `tz` and `TZ` are present with different values.
|
||||||
@@ -187,6 +187,10 @@ Safe fix: verify weatherfeeder is running and writing to the same database that
|
|||||||
`weatherapi` uses as its first configured database. For current conditions,
|
`weatherapi` uses as its first configured database. For current conditions,
|
||||||
wait for recent observations or inspect weatherfeeder ingestion.
|
wait for recent observations or inspect weatherfeeder ingestion.
|
||||||
|
|
||||||
|
For convective outlooks, also verify that weatherfeeder has applied its
|
||||||
|
`weather.outlook.v2` table shape and is writing `outlook_runs`, `outlooks`, and
|
||||||
|
`outlook_discussions`.
|
||||||
|
|
||||||
## Forecast Day Routes Return Empty `periods`
|
## Forecast Day Routes Return Empty `periods`
|
||||||
|
|
||||||
Symptom: `/forecast/hourly/today`, `/forecast/hourly/tomorrow`,
|
Symptom: `/forecast/hourly/today`, `/forecast/hourly/tomorrow`,
|
||||||
|
|||||||
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.11.0
|
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0
|
||||||
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.11.0 h1:4hRiktLMFS8Arl98j21YYskXT8RHHQGFDPJxOF3CpwU=
|
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0 h1:U3yln3o2rGqfMvWVRwOGgQeqYuqMm+/p0XIRhK8TDUQ=
|
||||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0/go.mod h1:VVtuwrbddWdUu21ovCSSojhH5J9P6kk0/dfnFqC4/Lw=
|
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0/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=
|
||||||
|
|||||||
@@ -1314,10 +1314,9 @@ func TestOutlookRoutesJSONSuccess(t *testing.T) {
|
|||||||
|
|
||||||
var payload struct {
|
var payload struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
LocationID string `json:"locationId"`
|
LocationID string `json:"locationId"`
|
||||||
Outlooks []struct {
|
Outlooks []map[string]any `json:"outlooks"`
|
||||||
ID string `json:"id"`
|
Discussions []outlookDiscussionCheck `json:"discussions"`
|
||||||
} `json:"outlooks"`
|
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||||
@@ -1326,9 +1325,20 @@ func TestOutlookRoutesJSONSuccess(t *testing.T) {
|
|||||||
if payload.Data.LocationID != "stl" {
|
if payload.Data.LocationID != "stl" {
|
||||||
t.Fatalf("expected locationId stl, got %q", payload.Data.LocationID)
|
t.Fatalf("expected locationId stl, got %q", payload.Data.LocationID)
|
||||||
}
|
}
|
||||||
if len(payload.Data.Outlooks) != 1 || payload.Data.Outlooks[0].ID != "cat-1" {
|
if len(payload.Data.Outlooks) != 1 || payload.Data.Outlooks[0]["id"] != "cat-1" {
|
||||||
t.Fatalf("unexpected outlooks payload: %+v", payload.Data.Outlooks)
|
t.Fatalf("unexpected outlooks payload: %+v", payload.Data.Outlooks)
|
||||||
}
|
}
|
||||||
|
for _, field := range []string{"headline", "summary", "discussion"} {
|
||||||
|
if _, ok := payload.Data.Outlooks[0][field]; ok {
|
||||||
|
t.Fatalf("expected outlook polygon to omit %s, got %+v", field, payload.Data.Outlooks[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(payload.Data.Discussions) != 1 {
|
||||||
|
t.Fatalf("expected one discussion, got %+v", payload.Data.Discussions)
|
||||||
|
}
|
||||||
|
if payload.Data.Discussions[0].Day != 1 || payload.Data.Discussions[0].Headline != "Day 1 headline" {
|
||||||
|
t.Fatalf("unexpected discussions payload: %+v", payload.Data.Discussions)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1369,7 +1379,8 @@ func TestOutlookTextResponseUsesTemplate(t *testing.T) {
|
|||||||
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"))
|
||||||
}
|
}
|
||||||
body := w.Body.String()
|
body := w.Body.String()
|
||||||
if !strings.Contains(body, "Convective Outlook") || !strings.Contains(body, "Outlooks: 1") {
|
if !strings.Contains(body, "Convective Outlook") || !strings.Contains(body, "Outlooks: 1") ||
|
||||||
|
!strings.Contains(body, "Discussions: 1") || !strings.Contains(body, "Day 1 discussion") {
|
||||||
t.Fatalf("expected outlook text template body, got %q", body)
|
t.Fatalf("expected outlook text template body, got %q", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1390,11 +1401,15 @@ func TestOutlookXMLResponseRenders(t *testing.T) {
|
|||||||
if !strings.Contains(w.Body.String(), "<LocationID>stl</LocationID>") {
|
if !strings.Contains(w.Body.String(), "<LocationID>stl</LocationID>") {
|
||||||
t.Fatalf("expected outlook XML payload, got %q", w.Body.String())
|
t.Fatalf("expected outlook XML payload, got %q", w.Body.String())
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "<Discussions>") || !strings.Contains(w.Body.String(), "<Headline>Day 1 headline</Headline>") {
|
||||||
|
t.Fatalf("expected outlook XML discussions, got %q", w.Body.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
|
func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
|
||||||
run := testOutlookRun()
|
run := testOutlookRun()
|
||||||
run.Outlooks = []model.WeatherOutlook{}
|
run.Outlooks = []model.WeatherOutlook{}
|
||||||
|
run.Discussions = []model.WeatherOutlookDiscussion{}
|
||||||
h := newHandler(t, &fakeService{outlookRun: run}, "/outlooks/convective")
|
h := newHandler(t, &fakeService{outlookRun: run}, "/outlooks/convective")
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -1407,8 +1422,9 @@ func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
|
|||||||
|
|
||||||
var payload struct {
|
var payload struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
LocationID string `json:"locationId"`
|
LocationID string `json:"locationId"`
|
||||||
Outlooks []model.WeatherOutlook `json:"outlooks"`
|
Outlooks []model.WeatherOutlook `json:"outlooks"`
|
||||||
|
Discussions []model.WeatherOutlookDiscussion `json:"discussions"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||||
@@ -1420,6 +1436,39 @@ func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
|
|||||||
if payload.Data.Outlooks == nil || len(payload.Data.Outlooks) != 0 {
|
if payload.Data.Outlooks == nil || len(payload.Data.Outlooks) != 0 {
|
||||||
t.Fatalf("expected empty outlooks slice, got %+v", payload.Data.Outlooks)
|
t.Fatalf("expected empty outlooks slice, got %+v", payload.Data.Outlooks)
|
||||||
}
|
}
|
||||||
|
if payload.Data.Discussions == nil || len(payload.Data.Discussions) != 0 {
|
||||||
|
t.Fatalf("expected empty discussions slice, got %+v", payload.Data.Discussions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutlookTimezoneQuery(t *testing.T) {
|
||||||
|
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, "/outlooks/convective")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?tz=CDT", nil)
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload outlookTimePayload
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode outlook payload: %v", err)
|
||||||
|
}
|
||||||
|
assertOffsetSeconds(t, payload.Data.AsOf, -5*60*60)
|
||||||
|
assertOffsetSeconds(t, *payload.Data.IssuedAt, -5*60*60)
|
||||||
|
if len(payload.Data.Outlooks) != 1 {
|
||||||
|
t.Fatalf("expected one outlook, got %+v", payload.Data.Outlooks)
|
||||||
|
}
|
||||||
|
assertOffsetSeconds(t, payload.Data.Outlooks[0].ValidFrom, -5*60*60)
|
||||||
|
assertOffsetSeconds(t, payload.Data.Outlooks[0].ValidTo, -5*60*60)
|
||||||
|
assertOffsetSeconds(t, payload.Data.Outlooks[0].IssuedAt, -5*60*60)
|
||||||
|
assertOffsetSeconds(t, payload.Data.Outlooks[0].ExpiresAt, -5*60*60)
|
||||||
|
if len(payload.Data.Discussions) != 1 || payload.Data.Discussions[0].UpdatedAt == nil {
|
||||||
|
t.Fatalf("expected one discussion with updatedAt, got %+v", payload.Data.Discussions)
|
||||||
|
}
|
||||||
|
assertOffsetSeconds(t, *payload.Data.Discussions[0].UpdatedAt, -5*60*60)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOutlookQueryParamsConstructFilter(t *testing.T) {
|
func TestOutlookQueryParamsConstructFilter(t *testing.T) {
|
||||||
@@ -2211,7 +2260,7 @@ func testRenderers(t *testing.T) *render.Registry {
|
|||||||
"discussion_long_term.txt.tmpl": "Forecast Discussion Long Term",
|
"discussion_long_term.txt.tmpl": "Forecast Discussion Long Term",
|
||||||
"forecast_hourly.txt.tmpl": "Forecast text",
|
"forecast_hourly.txt.tmpl": "Forecast text",
|
||||||
"forecast_narrative.txt.tmpl": "Narrative Forecast",
|
"forecast_narrative.txt.tmpl": "Narrative Forecast",
|
||||||
"outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}{{else}}No convective outlook data available.{{end}}",
|
"outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}\nDiscussions: {{len .Data.Discussions}}{{range .Data.Discussions}}\nDiscussion: {{.Discussion}}{{end}}{{else}}No convective outlook data available.{{end}}",
|
||||||
"weatherstories.txt.tmpl": "Weather Stories",
|
"weatherstories.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": "Alerts text",
|
||||||
@@ -2250,6 +2299,7 @@ func setOutlookNowForTest(t *testing.T, now time.Time) {
|
|||||||
|
|
||||||
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)
|
||||||
return &model.WeatherOutlookRun{
|
return &model.WeatherOutlookRun{
|
||||||
LocationID: "stl",
|
LocationID: "stl",
|
||||||
LocationName: "St. Louis",
|
LocationName: "St. Louis",
|
||||||
@@ -2270,6 +2320,13 @@ func testOutlookRun() *model.WeatherOutlookRun {
|
|||||||
ContainsLocation: true,
|
ContainsLocation: true,
|
||||||
Geometry: []byte(`{"type":"Point","coordinates":[-90.2,38.6]}`),
|
Geometry: []byte(`{"type":"Point","coordinates":[-90.2,38.6]}`),
|
||||||
}},
|
}},
|
||||||
|
Discussions: []model.WeatherOutlookDiscussion{{
|
||||||
|
Day: 1,
|
||||||
|
Headline: "Day 1 headline",
|
||||||
|
Summary: "Day 1 summary",
|
||||||
|
Discussion: "Day 1 discussion",
|
||||||
|
UpdatedAt: &discussionUpdatedAt,
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2343,6 +2400,27 @@ type discussionFocusedTimePayload struct {
|
|||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type outlookDiscussionCheck struct {
|
||||||
|
Day int `json:"day"`
|
||||||
|
Headline string `json:"headline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type outlookTimePayload struct {
|
||||||
|
Data struct {
|
||||||
|
AsOf time.Time `json:"asOf"`
|
||||||
|
IssuedAt *time.Time `json:"issuedAt"`
|
||||||
|
Outlooks []struct {
|
||||||
|
ValidFrom time.Time `json:"validFrom"`
|
||||||
|
ValidTo time.Time `json:"validTo"`
|
||||||
|
IssuedAt time.Time `json:"issuedAt"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
} `json:"outlooks"`
|
||||||
|
Discussions []struct {
|
||||||
|
UpdatedAt *time.Time `json:"updatedAt"`
|
||||||
|
} `json:"discussions"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
func decodeForecastTimePayload(t *testing.T, w *httptest.ResponseRecorder) forecastTimePayload {
|
func decodeForecastTimePayload(t *testing.T, w *httptest.ResponseRecorder) forecastTimePayload {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,14 @@ func OutlookRunPayload(run *model.WeatherOutlookRun, _ Units, tz *time.Location)
|
|||||||
AsOf: inLocationTime(run.AsOf, tz),
|
AsOf: inLocationTime(run.AsOf, tz),
|
||||||
IssuedAt: inLocationTimePtr(run.IssuedAt, tz),
|
IssuedAt: inLocationTimePtr(run.IssuedAt, tz),
|
||||||
Outlooks: make([]model.WeatherOutlook, 0, len(run.Outlooks)),
|
Outlooks: make([]model.WeatherOutlook, 0, len(run.Outlooks)),
|
||||||
|
Discussions: make([]model.WeatherOutlookDiscussion, 0, len(run.Discussions)),
|
||||||
}
|
}
|
||||||
for _, outlook := range run.Outlooks {
|
for _, outlook := range run.Outlooks {
|
||||||
out.Outlooks = append(out.Outlooks, copyOutlook(outlook, tz))
|
out.Outlooks = append(out.Outlooks, copyOutlook(outlook, tz))
|
||||||
}
|
}
|
||||||
|
for _, discussion := range run.Discussions {
|
||||||
|
out.Discussions = append(out.Discussions, copyOutlookDiscussion(discussion, tz))
|
||||||
|
}
|
||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,9 +48,6 @@ func copyOutlook(outlook model.WeatherOutlook, tz *time.Location) model.WeatherO
|
|||||||
IssuedAt: inLocationTime(outlook.IssuedAt, tz),
|
IssuedAt: inLocationTime(outlook.IssuedAt, tz),
|
||||||
ExpiresAt: inLocationTime(outlook.ExpiresAt, tz),
|
ExpiresAt: inLocationTime(outlook.ExpiresAt, tz),
|
||||||
Forecaster: outlook.Forecaster,
|
Forecaster: outlook.Forecaster,
|
||||||
Headline: outlook.Headline,
|
|
||||||
Summary: outlook.Summary,
|
|
||||||
Discussion: outlook.Discussion,
|
|
||||||
SourceURL: outlook.SourceURL,
|
SourceURL: outlook.SourceURL,
|
||||||
ImageURL: outlook.ImageURL,
|
ImageURL: outlook.ImageURL,
|
||||||
ContainsLocation: outlook.ContainsLocation,
|
ContainsLocation: outlook.ContainsLocation,
|
||||||
@@ -57,6 +58,16 @@ func copyOutlook(outlook model.WeatherOutlook, tz *time.Location) model.WeatherO
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func copyOutlookDiscussion(discussion model.WeatherOutlookDiscussion, tz *time.Location) model.WeatherOutlookDiscussion {
|
||||||
|
return model.WeatherOutlookDiscussion{
|
||||||
|
Day: discussion.Day,
|
||||||
|
Headline: discussion.Headline,
|
||||||
|
Summary: discussion.Summary,
|
||||||
|
Discussion: discussion.Discussion,
|
||||||
|
UpdatedAt: inLocationTimePtr(discussion.UpdatedAt, tz),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func copyIntPtr(v *int) *int {
|
func copyIntPtr(v *int) *int {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -440,6 +440,7 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
|||||||
loc := time.FixedZone("UTC-05:00", -5*60*60)
|
loc := time.FixedZone("UTC-05:00", -5*60*60)
|
||||||
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
|
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
|
||||||
issuedAt := asOf.Add(-1 * time.Hour)
|
issuedAt := asOf.Add(-1 * time.Hour)
|
||||||
|
discussionUpdatedAt := asOf.Add(-30 * time.Minute)
|
||||||
severityRank := 5
|
severityRank := 5
|
||||||
latitude := 38.627123
|
latitude := 38.627123
|
||||||
longitude := -90.199456
|
longitude := -90.199456
|
||||||
@@ -465,14 +466,18 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
|||||||
IssuedAt: issuedAt,
|
IssuedAt: issuedAt,
|
||||||
ExpiresAt: asOf.Add(6 * time.Hour),
|
ExpiresAt: asOf.Add(6 * time.Hour),
|
||||||
Forecaster: "DIAL",
|
Forecaster: "DIAL",
|
||||||
Headline: "Severe storms possible",
|
|
||||||
Summary: "Scattered severe storms are possible.",
|
|
||||||
Discussion: "Discussion text.",
|
|
||||||
SourceURL: "https://example.test/source",
|
SourceURL: "https://example.test/source",
|
||||||
ImageURL: "https://example.test/image.png",
|
ImageURL: "https://example.test/image.png",
|
||||||
ContainsLocation: true,
|
ContainsLocation: true,
|
||||||
Geometry: geometry,
|
Geometry: geometry,
|
||||||
}},
|
}},
|
||||||
|
Discussions: []model.WeatherOutlookDiscussion{{
|
||||||
|
Day: 1,
|
||||||
|
Headline: "Severe storms possible",
|
||||||
|
Summary: "Scattered severe storms are possible.",
|
||||||
|
Discussion: "Discussion text.",
|
||||||
|
UpdatedAt: &discussionUpdatedAt,
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := OutlookRunPayload(run, UnitsUS, loc)
|
payload := OutlookRunPayload(run, UnitsUS, loc)
|
||||||
@@ -489,9 +494,15 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
|||||||
if len(out.Outlooks) != 1 {
|
if len(out.Outlooks) != 1 {
|
||||||
t.Fatalf("expected one outlook, got %d", len(out.Outlooks))
|
t.Fatalf("expected one outlook, got %d", len(out.Outlooks))
|
||||||
}
|
}
|
||||||
|
if len(out.Discussions) != 1 {
|
||||||
|
t.Fatalf("expected one discussion, got %d", len(out.Discussions))
|
||||||
|
}
|
||||||
if out.Outlooks[0].SeverityRank == run.Outlooks[0].SeverityRank {
|
if out.Outlooks[0].SeverityRank == run.Outlooks[0].SeverityRank {
|
||||||
t.Fatalf("expected severity rank pointer to be copied")
|
t.Fatalf("expected severity rank pointer to be copied")
|
||||||
}
|
}
|
||||||
|
if out.Discussions[0].UpdatedAt == run.Discussions[0].UpdatedAt {
|
||||||
|
t.Fatalf("expected discussion updatedAt pointer to be copied")
|
||||||
|
}
|
||||||
if &out.Outlooks[0].Geometry[0] == &run.Outlooks[0].Geometry[0] {
|
if &out.Outlooks[0].Geometry[0] == &run.Outlooks[0].Geometry[0] {
|
||||||
t.Fatalf("expected geometry bytes to be copied")
|
t.Fatalf("expected geometry bytes to be copied")
|
||||||
}
|
}
|
||||||
@@ -502,6 +513,7 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
|||||||
assertOffsetSeconds(t, out.Outlooks[0].ValidTo, -5*60*60)
|
assertOffsetSeconds(t, out.Outlooks[0].ValidTo, -5*60*60)
|
||||||
assertOffsetSeconds(t, out.Outlooks[0].IssuedAt, -5*60*60)
|
assertOffsetSeconds(t, out.Outlooks[0].IssuedAt, -5*60*60)
|
||||||
assertOffsetSeconds(t, out.Outlooks[0].ExpiresAt, -5*60*60)
|
assertOffsetSeconds(t, out.Outlooks[0].ExpiresAt, -5*60*60)
|
||||||
|
assertOffsetSeconds(t, *out.Discussions[0].UpdatedAt, -5*60*60)
|
||||||
if !out.AsOf.UTC().Equal(asOf) || !out.Outlooks[0].ValidFrom.UTC().Equal(asOf) {
|
if !out.AsOf.UTC().Equal(asOf) || !out.Outlooks[0].ValidFrom.UTC().Equal(asOf) {
|
||||||
t.Fatalf("expected timezone conversion to preserve instants")
|
t.Fatalf("expected timezone conversion to preserve instants")
|
||||||
}
|
}
|
||||||
@@ -515,12 +527,19 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
|||||||
if string(out.Outlooks[0].Geometry) != string(geometry) {
|
if string(out.Outlooks[0].Geometry) != string(geometry) {
|
||||||
t.Fatalf("expected geometry bytes preserved, got %s", out.Outlooks[0].Geometry)
|
t.Fatalf("expected geometry bytes preserved, got %s", out.Outlooks[0].Geometry)
|
||||||
}
|
}
|
||||||
|
if out.Discussions[0].Day != 1 || out.Discussions[0].Headline != "Severe storms possible" ||
|
||||||
|
out.Discussions[0].Summary != "Scattered severe storms are possible." ||
|
||||||
|
out.Discussions[0].Discussion != "Discussion text." {
|
||||||
|
t.Fatalf("expected discussion fields preserved, got %+v", out.Discussions[0])
|
||||||
|
}
|
||||||
|
|
||||||
*out.Latitude = 99
|
*out.Latitude = 99
|
||||||
*out.Longitude = -99
|
*out.Longitude = -99
|
||||||
*out.IssuedAt = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
|
*out.IssuedAt = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
*out.Outlooks[0].SeverityRank = 99
|
*out.Outlooks[0].SeverityRank = 99
|
||||||
|
*out.Discussions[0].UpdatedAt = time.Date(2031, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
out.Outlooks[0].Geometry[0] = '['
|
out.Outlooks[0].Geometry[0] = '['
|
||||||
|
out.Discussions[0].Headline = "changed"
|
||||||
if *run.Latitude != latitude || *run.Longitude != longitude || !run.IssuedAt.Equal(issuedAt) {
|
if *run.Latitude != latitude || *run.Longitude != longitude || !run.IssuedAt.Equal(issuedAt) {
|
||||||
t.Fatalf("expected source run pointers not to mutate")
|
t.Fatalf("expected source run pointers not to mutate")
|
||||||
}
|
}
|
||||||
@@ -530,8 +549,15 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
|||||||
if string(run.Outlooks[0].Geometry) != string(geometry) {
|
if string(run.Outlooks[0].Geometry) != string(geometry) {
|
||||||
t.Fatalf("expected source geometry not to mutate, got %s", run.Outlooks[0].Geometry)
|
t.Fatalf("expected source geometry not to mutate, got %s", run.Outlooks[0].Geometry)
|
||||||
}
|
}
|
||||||
|
if !run.Discussions[0].UpdatedAt.Equal(discussionUpdatedAt) {
|
||||||
|
t.Fatalf("expected source discussion updatedAt not to mutate, got %v", run.Discussions[0].UpdatedAt)
|
||||||
|
}
|
||||||
|
if run.Discussions[0].Headline != "Severe storms possible" {
|
||||||
|
t.Fatalf("expected source discussion headline not to mutate, got %q", run.Discussions[0].Headline)
|
||||||
|
}
|
||||||
assertOffsetSeconds(t, run.AsOf, 0)
|
assertOffsetSeconds(t, run.AsOf, 0)
|
||||||
assertOffsetSeconds(t, run.Outlooks[0].ValidFrom, 0)
|
assertOffsetSeconds(t, run.Outlooks[0].ValidFrom, 0)
|
||||||
|
assertOffsetSeconds(t, *run.Discussions[0].UpdatedAt, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func float64Ptr(v float64) *float64 {
|
func float64Ptr(v float64) *float64 {
|
||||||
|
|||||||
@@ -41,9 +41,6 @@ func mapOutlookRow(row outlookRow) (model.WeatherOutlook, error) {
|
|||||||
IssuedAt: row.IssuedAt.UTC(),
|
IssuedAt: row.IssuedAt.UTC(),
|
||||||
ExpiresAt: row.ExpiresAt.UTC(),
|
ExpiresAt: row.ExpiresAt.UTC(),
|
||||||
Forecaster: stringValue(row.Forecaster),
|
Forecaster: stringValue(row.Forecaster),
|
||||||
Headline: stringValue(row.Headline),
|
|
||||||
Summary: stringValue(row.Summary),
|
|
||||||
Discussion: stringValue(row.Discussion),
|
|
||||||
SourceURL: stringValue(row.SourceURL),
|
SourceURL: stringValue(row.SourceURL),
|
||||||
ImageURL: stringValue(row.ImageURL),
|
ImageURL: stringValue(row.ImageURL),
|
||||||
ContainsLocation: row.ContainsLocation,
|
ContainsLocation: row.ContainsLocation,
|
||||||
@@ -51,6 +48,16 @@ func mapOutlookRow(row outlookRow) (model.WeatherOutlook, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mapOutlookDiscussionRow(row outlookDiscussionRow) model.WeatherOutlookDiscussion {
|
||||||
|
return model.WeatherOutlookDiscussion{
|
||||||
|
Day: row.Day,
|
||||||
|
Headline: stringValue(row.Headline),
|
||||||
|
Summary: stringValue(row.Summary),
|
||||||
|
Discussion: stringValue(row.Discussion),
|
||||||
|
UpdatedAt: timePtr(row.UpdatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func intPtr(v sql.NullInt64) *int {
|
func intPtr(v sql.NullInt64) *int {
|
||||||
if !v.Valid {
|
if !v.Valid {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -79,9 +79,6 @@ func TestMapOutlookRowMapsFields(t *testing.T) {
|
|||||||
IssuedAt: issuedAt,
|
IssuedAt: issuedAt,
|
||||||
ExpiresAt: expiresAt,
|
ExpiresAt: expiresAt,
|
||||||
Forecaster: sql.NullString{String: "DIAL", Valid: true},
|
Forecaster: sql.NullString{String: "DIAL", Valid: true},
|
||||||
Headline: sql.NullString{String: "Severe storms possible", Valid: true},
|
|
||||||
Summary: sql.NullString{String: "Scattered severe storms are possible.", Valid: true},
|
|
||||||
Discussion: sql.NullString{String: "Discussion text.", Valid: true},
|
|
||||||
SourceURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.html", Valid: true},
|
SourceURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.html", Valid: true},
|
||||||
ImageURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.gif", Valid: true},
|
ImageURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.gif", Valid: true},
|
||||||
ContainsLocation: true,
|
ContainsLocation: true,
|
||||||
@@ -103,8 +100,8 @@ func TestMapOutlookRowMapsFields(t *testing.T) {
|
|||||||
if outlook.SeverityRank == nil || *outlook.SeverityRank != int(severityRank) {
|
if outlook.SeverityRank == nil || *outlook.SeverityRank != int(severityRank) {
|
||||||
t.Fatalf("expected severity rank %d, got %v", severityRank, outlook.SeverityRank)
|
t.Fatalf("expected severity rank %d, got %v", severityRank, outlook.SeverityRank)
|
||||||
}
|
}
|
||||||
if outlook.Forecaster != "DIAL" || outlook.Headline == "" || outlook.Summary == "" || outlook.Discussion == "" {
|
if outlook.Forecaster != "DIAL" {
|
||||||
t.Fatalf("unexpected text fields: %+v", outlook)
|
t.Fatalf("unexpected forecaster: %+v", outlook)
|
||||||
}
|
}
|
||||||
if outlook.SourceURL == "" || outlook.ImageURL == "" {
|
if outlook.SourceURL == "" || outlook.ImageURL == "" {
|
||||||
t.Fatalf("expected source and image URLs: %+v", outlook)
|
t.Fatalf("expected source and image URLs: %+v", outlook)
|
||||||
@@ -142,8 +139,7 @@ func TestMapOutlookRowMissingOptionals(t *testing.T) {
|
|||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if outlook.LabelText != "" || outlook.Forecaster != "" || outlook.Headline != "" ||
|
if outlook.LabelText != "" || outlook.Forecaster != "" || outlook.SourceURL != "" || outlook.ImageURL != "" {
|
||||||
outlook.Summary != "" || outlook.Discussion != "" || outlook.SourceURL != "" || outlook.ImageURL != "" {
|
|
||||||
t.Fatalf("expected optional strings to map to empty values, got %+v", outlook)
|
t.Fatalf("expected optional strings to map to empty values, got %+v", outlook)
|
||||||
}
|
}
|
||||||
if outlook.SeverityRank != nil {
|
if outlook.SeverityRank != nil {
|
||||||
@@ -172,3 +168,49 @@ func TestMapOutlookRowRejectsInvalidGeometry(t *testing.T) {
|
|||||||
t.Fatal("expected invalid geometry error")
|
t.Fatal("expected invalid geometry error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMapOutlookDiscussionRowMapsFields(t *testing.T) {
|
||||||
|
updatedAt := time.Date(2026, 6, 11, 7, 30, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||||
|
|
||||||
|
discussion := mapOutlookDiscussionRow(outlookDiscussionRow{
|
||||||
|
DiscussionIndex: 2,
|
||||||
|
Day: 2,
|
||||||
|
Headline: sql.NullString{String: "Severe storms possible", Valid: true},
|
||||||
|
Summary: sql.NullString{String: "Scattered severe storms are possible.", Valid: true},
|
||||||
|
Discussion: sql.NullString{String: "Discussion text.", Valid: true},
|
||||||
|
UpdatedAt: sql.NullTime{Time: updatedAt, Valid: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
if discussion.Day != 2 {
|
||||||
|
t.Fatalf("expected day 2, got %d", discussion.Day)
|
||||||
|
}
|
||||||
|
if discussion.Headline != "Severe storms possible" {
|
||||||
|
t.Fatalf("unexpected headline: %q", discussion.Headline)
|
||||||
|
}
|
||||||
|
if discussion.Summary != "Scattered severe storms are possible." {
|
||||||
|
t.Fatalf("unexpected summary: %q", discussion.Summary)
|
||||||
|
}
|
||||||
|
if discussion.Discussion != "Discussion text." {
|
||||||
|
t.Fatalf("unexpected discussion: %q", discussion.Discussion)
|
||||||
|
}
|
||||||
|
if discussion.UpdatedAt == nil || discussion.UpdatedAt.Location().String() != "UTC" {
|
||||||
|
t.Fatalf("expected updatedAt UTC pointer, got %v", discussion.UpdatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapOutlookDiscussionRowMissingOptionals(t *testing.T) {
|
||||||
|
discussion := mapOutlookDiscussionRow(outlookDiscussionRow{
|
||||||
|
DiscussionIndex: 1,
|
||||||
|
Day: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
if discussion.Day != 1 {
|
||||||
|
t.Fatalf("expected day 1, got %d", discussion.Day)
|
||||||
|
}
|
||||||
|
if discussion.Headline != "" || discussion.Summary != "" || discussion.Discussion != "" {
|
||||||
|
t.Fatalf("expected empty optional strings, got %+v", discussion)
|
||||||
|
}
|
||||||
|
if discussion.UpdatedAt != nil {
|
||||||
|
t.Fatalf("expected nil updatedAt, got %v", discussion.UpdatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ SELECT
|
|||||||
issued_at,
|
issued_at,
|
||||||
expires_at,
|
expires_at,
|
||||||
forecaster,
|
forecaster,
|
||||||
headline,
|
|
||||||
summary,
|
|
||||||
discussion,
|
|
||||||
source_url,
|
source_url,
|
||||||
image_url,
|
image_url,
|
||||||
contains_location,
|
contains_location,
|
||||||
@@ -42,4 +39,16 @@ SELECT
|
|||||||
FROM outlooks
|
FROM outlooks
|
||||||
WHERE run_event_id = $1
|
WHERE run_event_id = $1
|
||||||
ORDER BY outlook_index ASC`
|
ORDER BY outlook_index ASC`
|
||||||
|
|
||||||
|
queryOutlookDiscussionsForRun = `
|
||||||
|
SELECT
|
||||||
|
discussion_index,
|
||||||
|
day,
|
||||||
|
headline,
|
||||||
|
summary,
|
||||||
|
discussion,
|
||||||
|
updated_at
|
||||||
|
FROM outlook_discussions
|
||||||
|
WHERE run_event_id = $1
|
||||||
|
ORDER BY discussion_index ASC`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ func (r *Repository) LatestConvectiveOutlookRun(ctx context.Context) (*model.Wea
|
|||||||
}
|
}
|
||||||
run.Outlooks = outlooks
|
run.Outlooks = outlooks
|
||||||
|
|
||||||
|
discussions, err := r.loadOutlookDiscussions(ctx, row.EventID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
run.Discussions = discussions
|
||||||
|
|
||||||
return &run, nil
|
return &run, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,9 +74,6 @@ func (r *Repository) loadOutlooks(ctx context.Context, eventID string) ([]model.
|
|||||||
&row.IssuedAt,
|
&row.IssuedAt,
|
||||||
&row.ExpiresAt,
|
&row.ExpiresAt,
|
||||||
&row.Forecaster,
|
&row.Forecaster,
|
||||||
&row.Headline,
|
|
||||||
&row.Summary,
|
|
||||||
&row.Discussion,
|
|
||||||
&row.SourceURL,
|
&row.SourceURL,
|
||||||
&row.ImageURL,
|
&row.ImageURL,
|
||||||
&row.ContainsLocation,
|
&row.ContainsLocation,
|
||||||
@@ -90,3 +93,32 @@ func (r *Repository) loadOutlooks(ctx context.Context, eventID string) ([]model.
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) loadOutlookDiscussions(ctx context.Context, eventID string) ([]model.WeatherOutlookDiscussion, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, queryOutlookDiscussionsForRun, eventID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query outlook discussions: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make([]model.WeatherOutlookDiscussion, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var row outlookDiscussionRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&row.DiscussionIndex,
|
||||||
|
&row.Day,
|
||||||
|
&row.Headline,
|
||||||
|
&row.Summary,
|
||||||
|
&row.Discussion,
|
||||||
|
&row.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan outlook discussion row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, mapOutlookDiscussionRow(row))
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate outlook discussion rows: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
405
internal/adapters/outbound/postgres/outlooks_read_test.go
Normal file
405
internal/adapters/outbound/postgres/outlooks_read_test.go
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
// outlooks_read_test.go validates outlook repository read flow.
|
||||||
|
// Layer: adapters/outbound/postgres outlook read tests.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const outlookTestDriverName = "weatherapi_outlook_read_test"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
sql.Register(outlookTestDriverName, outlookTestDriver{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatestConvectiveOutlookRunLoadsOutlooksAndDiscussions(t *testing.T) {
|
||||||
|
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
|
||||||
|
issuedAt := asOf.Add(-1 * time.Hour)
|
||||||
|
discussionUpdated := asOf.Add(-30 * time.Minute)
|
||||||
|
repo, closeDB := openOutlookTestRepository(t,
|
||||||
|
outlookParentQuery([][]driver.Value{{
|
||||||
|
"evt-outlook-run",
|
||||||
|
"stl",
|
||||||
|
"St. Louis",
|
||||||
|
float64(38.62),
|
||||||
|
float64(-90.2),
|
||||||
|
asOf,
|
||||||
|
issuedAt,
|
||||||
|
}}),
|
||||||
|
outlookRowsQuery([][]driver.Value{
|
||||||
|
outlookReadRow(1, "day-1", 1, "categorical"),
|
||||||
|
outlookReadRow(2, "day-2", 2, "wind"),
|
||||||
|
}, nil),
|
||||||
|
outlookDiscussionsQuery([][]driver.Value{
|
||||||
|
{int64(1), int64(1), "Day 1 headline", "Day 1 summary", "Day 1 discussion", discussionUpdated},
|
||||||
|
{int64(2), int64(2), "Day 2 headline", nil, "Day 2 discussion", nil},
|
||||||
|
}, nil),
|
||||||
|
)
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
run, err := repo.LatestConvectiveOutlookRun(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if run == nil {
|
||||||
|
t.Fatal("expected outlook run")
|
||||||
|
}
|
||||||
|
if run.LocationID != "stl" || run.LocationName != "St. Louis" {
|
||||||
|
t.Fatalf("unexpected run metadata: %+v", run)
|
||||||
|
}
|
||||||
|
if len(run.Outlooks) != 2 {
|
||||||
|
t.Fatalf("expected 2 outlooks, got %d", len(run.Outlooks))
|
||||||
|
}
|
||||||
|
if run.Outlooks[0].ID != "day-1" || run.Outlooks[1].ID != "day-2" {
|
||||||
|
t.Fatalf("expected outlook order from rows, got %+v", run.Outlooks)
|
||||||
|
}
|
||||||
|
if len(run.Discussions) != 2 {
|
||||||
|
t.Fatalf("expected 2 discussions, got %d", len(run.Discussions))
|
||||||
|
}
|
||||||
|
if run.Discussions[0].Day != 1 || run.Discussions[0].Headline != "Day 1 headline" {
|
||||||
|
t.Fatalf("unexpected first discussion: %+v", run.Discussions[0])
|
||||||
|
}
|
||||||
|
if run.Discussions[1].Day != 2 || run.Discussions[1].Summary != "" {
|
||||||
|
t.Fatalf("unexpected second discussion: %+v", run.Discussions[1])
|
||||||
|
}
|
||||||
|
if run.Discussions[0].UpdatedAt == nil || run.Discussions[0].UpdatedAt.Location().String() != "UTC" {
|
||||||
|
t.Fatalf("expected discussion updatedAt UTC pointer, got %v", run.Discussions[0].UpdatedAt)
|
||||||
|
}
|
||||||
|
if run.Discussions[1].UpdatedAt != nil {
|
||||||
|
t.Fatalf("expected nil discussion updatedAt, got %v", run.Discussions[1].UpdatedAt)
|
||||||
|
}
|
||||||
|
assertOutlookTestQueriesConsumed(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatestConvectiveOutlookRunMissingParentReturnsNil(t *testing.T) {
|
||||||
|
repo, closeDB := openOutlookTestRepository(t, outlookParentQuery(nil))
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
run, err := repo.LatestConvectiveOutlookRun(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if run != nil {
|
||||||
|
t.Fatalf("expected nil run, got %+v", run)
|
||||||
|
}
|
||||||
|
assertOutlookTestQueriesConsumed(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatestConvectiveOutlookRunWrapsReadErrors(t *testing.T) {
|
||||||
|
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
queries []scriptedOutlookQuery
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "parent query",
|
||||||
|
queries: []scriptedOutlookQuery{
|
||||||
|
{name: "parent", query: queryLatestConvectiveOutlookRun, err: errors.New("parent unavailable")},
|
||||||
|
},
|
||||||
|
want: "query latest convective outlook run",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "outlooks query",
|
||||||
|
queries: []scriptedOutlookQuery{
|
||||||
|
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||||
|
{name: "outlooks", query: queryOutlooksForRun, args: []driver.Value{"evt-outlook-run"}, err: errors.New("outlooks unavailable")},
|
||||||
|
},
|
||||||
|
want: "query outlooks",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "outlook scan",
|
||||||
|
queries: []scriptedOutlookQuery{
|
||||||
|
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||||
|
outlookRowsQuery([][]driver.Value{
|
||||||
|
{int64(1), "day-1"},
|
||||||
|
}, nil),
|
||||||
|
},
|
||||||
|
want: "scan outlook row",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "outlook map",
|
||||||
|
queries: []scriptedOutlookQuery{
|
||||||
|
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||||
|
outlookRowsQuery([][]driver.Value{
|
||||||
|
outlookReadRowWithGeometry(1, "day-1", 1, "categorical", `{"type":"Point"`),
|
||||||
|
}, nil),
|
||||||
|
},
|
||||||
|
want: "map outlook row",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "outlook iteration",
|
||||||
|
queries: []scriptedOutlookQuery{
|
||||||
|
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||||
|
outlookRowsQuery([][]driver.Value{
|
||||||
|
outlookReadRow(1, "day-1", 1, "categorical"),
|
||||||
|
}, errors.New("outlook iteration failed")),
|
||||||
|
},
|
||||||
|
want: "iterate outlook rows",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "discussions query",
|
||||||
|
queries: []scriptedOutlookQuery{
|
||||||
|
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||||
|
outlookRowsQuery(nil, nil),
|
||||||
|
{name: "discussions", query: queryOutlookDiscussionsForRun, args: []driver.Value{"evt-outlook-run"}, err: errors.New("discussions unavailable")},
|
||||||
|
},
|
||||||
|
want: "query outlook discussions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "discussion scan",
|
||||||
|
queries: []scriptedOutlookQuery{
|
||||||
|
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||||
|
outlookRowsQuery(nil, nil),
|
||||||
|
outlookDiscussionsQuery([][]driver.Value{
|
||||||
|
{int64(1), "bad day", nil, nil, nil, nil},
|
||||||
|
}, nil),
|
||||||
|
},
|
||||||
|
want: "scan outlook discussion row",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "discussion iteration",
|
||||||
|
queries: []scriptedOutlookQuery{
|
||||||
|
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||||
|
outlookRowsQuery(nil, nil),
|
||||||
|
outlookDiscussionsQuery([][]driver.Value{
|
||||||
|
{int64(1), int64(1), nil, nil, nil, nil},
|
||||||
|
}, errors.New("discussion iteration failed")),
|
||||||
|
},
|
||||||
|
want: "iterate outlook discussion rows",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
repo, closeDB := openOutlookTestRepository(t, tt.queries...)
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
_, err := repo.LatestConvectiveOutlookRun(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.want) {
|
||||||
|
t.Fatalf("expected error containing %q, got %v", tt.want, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openOutlookTestRepository(t *testing.T, queries ...scriptedOutlookQuery) (*Repository, func()) {
|
||||||
|
t.Helper()
|
||||||
|
outlookTestScript.set(queries)
|
||||||
|
|
||||||
|
db, err := sql.Open(outlookTestDriverName, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open test db: %v", err)
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
return NewRepository(db), func() {
|
||||||
|
_ = db.Close()
|
||||||
|
outlookTestScript.set(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertOutlookTestQueriesConsumed(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if remaining := outlookTestScript.remaining(); remaining != 0 {
|
||||||
|
t.Fatalf("expected all scripted queries consumed, got %d remaining", remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func outlookParentQuery(rows [][]driver.Value) scriptedOutlookQuery {
|
||||||
|
return scriptedOutlookQuery{
|
||||||
|
name: "parent",
|
||||||
|
query: queryLatestConvectiveOutlookRun,
|
||||||
|
columns: []string{"event_id", "location_id", "location_name", "latitude", "longitude", "as_of", "issued_at"},
|
||||||
|
rows: rows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func outlookRowsQuery(rows [][]driver.Value, nextErr error) scriptedOutlookQuery {
|
||||||
|
return scriptedOutlookQuery{
|
||||||
|
name: "outlooks",
|
||||||
|
query: queryOutlooksForRun,
|
||||||
|
args: []driver.Value{"evt-outlook-run"},
|
||||||
|
columns: []string{"outlook_index", "outlook_id", "provider", "product", "day", "outlook_type", "label", "label_text", "severity_rank", "valid_from", "valid_to", "issued_at", "expires_at", "forecaster", "source_url", "image_url", "contains_location", "geometry_json"},
|
||||||
|
rows: rows,
|
||||||
|
nextErr: nextErr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func outlookDiscussionsQuery(rows [][]driver.Value, nextErr error) scriptedOutlookQuery {
|
||||||
|
return scriptedOutlookQuery{
|
||||||
|
name: "discussions",
|
||||||
|
query: queryOutlookDiscussionsForRun,
|
||||||
|
args: []driver.Value{"evt-outlook-run"},
|
||||||
|
columns: []string{"discussion_index", "day", "headline", "summary", "discussion", "updated_at"},
|
||||||
|
rows: rows,
|
||||||
|
nextErr: nextErr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func outlookReadRow(index int64, id string, day int64, outlookType string) []driver.Value {
|
||||||
|
return outlookReadRowWithGeometry(index, id, day, outlookType, `{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,38]]]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func outlookReadRowWithGeometry(index int64, id string, day int64, outlookType string, geometry string) []driver.Value {
|
||||||
|
validFrom := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
||||||
|
validTo := validFrom.Add(6 * time.Hour)
|
||||||
|
return []driver.Value{
|
||||||
|
index,
|
||||||
|
id,
|
||||||
|
"spc",
|
||||||
|
"convective",
|
||||||
|
day,
|
||||||
|
outlookType,
|
||||||
|
"SLGT",
|
||||||
|
"Slight Risk",
|
||||||
|
int64(5),
|
||||||
|
validFrom,
|
||||||
|
validTo,
|
||||||
|
validFrom.Add(-1 * time.Hour),
|
||||||
|
validTo,
|
||||||
|
"DIAL",
|
||||||
|
"https://example.test/source",
|
||||||
|
"https://example.test/image.png",
|
||||||
|
true,
|
||||||
|
geometry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type outlookTestDriver struct{}
|
||||||
|
|
||||||
|
func (outlookTestDriver) Open(string) (driver.Conn, error) {
|
||||||
|
return outlookTestConn{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type outlookTestConn struct{}
|
||||||
|
|
||||||
|
func (outlookTestConn) Prepare(string) (driver.Stmt, error) {
|
||||||
|
return nil, errors.New("prepare is not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (outlookTestConn) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (outlookTestConn) Begin() (driver.Tx, error) {
|
||||||
|
return nil, errors.New("transactions are not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (outlookTestConn) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
|
||||||
|
return outlookTestScript.next(query, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
type scriptedOutlookQuery struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
args []driver.Value
|
||||||
|
columns []string
|
||||||
|
rows [][]driver.Value
|
||||||
|
err error
|
||||||
|
nextErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
type outlookTestScriptState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
queries []scriptedOutlookQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
var outlookTestScript outlookTestScriptState
|
||||||
|
|
||||||
|
func (s *outlookTestScriptState) set(queries []scriptedOutlookQuery) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.queries = append([]scriptedOutlookQuery(nil), queries...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *outlookTestScriptState) remaining() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.queries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *outlookTestScriptState) next(query string, args []driver.NamedValue) (driver.Rows, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if len(s.queries) == 0 {
|
||||||
|
return nil, fmt.Errorf("unexpected query: %s", compactSQL(query))
|
||||||
|
}
|
||||||
|
next := s.queries[0]
|
||||||
|
s.queries = s.queries[1:]
|
||||||
|
|
||||||
|
if compactSQL(query) != compactSQL(next.query) {
|
||||||
|
return nil, fmt.Errorf("expected %s query %q, got %q", next.name, compactSQL(next.query), compactSQL(query))
|
||||||
|
}
|
||||||
|
if len(args) != len(next.args) {
|
||||||
|
return nil, fmt.Errorf("expected %s args %v, got %v", next.name, next.args, namedValues(args))
|
||||||
|
}
|
||||||
|
for i, arg := range args {
|
||||||
|
if arg.Value != next.args[i] {
|
||||||
|
return nil, fmt.Errorf("expected %s arg %d to be %v, got %v", next.name, i, next.args[i], arg.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if next.err != nil {
|
||||||
|
return nil, next.err
|
||||||
|
}
|
||||||
|
return &outlookTestRows{
|
||||||
|
columns: append([]string(nil), next.columns...),
|
||||||
|
rows: append([][]driver.Value(nil), next.rows...),
|
||||||
|
nextErr: next.nextErr,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type outlookTestRows struct {
|
||||||
|
columns []string
|
||||||
|
rows [][]driver.Value
|
||||||
|
index int
|
||||||
|
nextErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *outlookTestRows) Columns() []string {
|
||||||
|
return r.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *outlookTestRows) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *outlookTestRows) Next(dest []driver.Value) error {
|
||||||
|
if r.index >= len(r.rows) {
|
||||||
|
if r.nextErr != nil {
|
||||||
|
err := r.nextErr
|
||||||
|
r.nextErr = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
copy(dest, r.rows[r.index])
|
||||||
|
r.index++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func compactSQL(query string) string {
|
||||||
|
return strings.Join(strings.Fields(query), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func namedValues(args []driver.NamedValue) []driver.Value {
|
||||||
|
out := make([]driver.Value, len(args))
|
||||||
|
for i := range args {
|
||||||
|
out[i] = args[i].Value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -32,11 +32,17 @@ type outlookRow struct {
|
|||||||
IssuedAt time.Time
|
IssuedAt time.Time
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
Forecaster sql.NullString
|
Forecaster sql.NullString
|
||||||
Headline sql.NullString
|
|
||||||
Summary sql.NullString
|
|
||||||
Discussion sql.NullString
|
|
||||||
SourceURL sql.NullString
|
SourceURL sql.NullString
|
||||||
ImageURL sql.NullString
|
ImageURL sql.NullString
|
||||||
ContainsLocation bool
|
ContainsLocation bool
|
||||||
GeometryJSON string
|
GeometryJSON string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type outlookDiscussionRow struct {
|
||||||
|
DiscussionIndex int
|
||||||
|
Day int
|
||||||
|
Headline sql.NullString
|
||||||
|
Summary sql.NullString
|
||||||
|
Discussion sql.NullString
|
||||||
|
UpdatedAt sql.NullTime
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ func (s *Service) LatestConvectiveOutlook(ctx context.Context, filter OutlookFil
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.Outlooks = outlooks
|
out.Outlooks = outlooks
|
||||||
|
out.Discussions = filterOutlookDiscussions(out.Discussions, out.Outlooks)
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +124,12 @@ func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
|
|||||||
out.Outlooks[i] = cloneOutlook(run.Outlooks[i])
|
out.Outlooks[i] = cloneOutlook(run.Outlooks[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if run.Discussions != nil {
|
||||||
|
out.Discussions = make([]model.WeatherOutlookDiscussion, len(run.Discussions))
|
||||||
|
for i := range run.Discussions {
|
||||||
|
out.Discussions[i] = cloneOutlookDiscussion(run.Discussions[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +142,31 @@ func cloneOutlook(outlook model.WeatherOutlook) model.WeatherOutlook {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneOutlookDiscussion(discussion model.WeatherOutlookDiscussion) model.WeatherOutlookDiscussion {
|
||||||
|
out := discussion
|
||||||
|
out.UpdatedAt = copyTime(discussion.UpdatedAt)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterOutlookDiscussions(discussions []model.WeatherOutlookDiscussion, outlooks []model.WeatherOutlook) []model.WeatherOutlookDiscussion {
|
||||||
|
if len(outlooks) == 0 {
|
||||||
|
return []model.WeatherOutlookDiscussion{}
|
||||||
|
}
|
||||||
|
|
||||||
|
retainedDays := make(map[int]struct{}, len(outlooks))
|
||||||
|
for _, outlook := range outlooks {
|
||||||
|
retainedDays[outlook.Day] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := discussions[:0]
|
||||||
|
for _, discussion := range discussions {
|
||||||
|
if _, ok := retainedDays[discussion.Day]; ok {
|
||||||
|
out = append(out, discussion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func copyFloat64(value *float64) *float64 {
|
func copyFloat64(value *float64) *float64 {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -131,6 +132,7 @@ func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
|
|||||||
if run == nil || run.LocationID != "stl" {
|
if run == nil || run.LocationID != "stl" {
|
||||||
t.Fatalf("unexpected outlook run: %+v", run)
|
t.Fatalf("unexpected outlook run: %+v", run)
|
||||||
}
|
}
|
||||||
|
assertDiscussionDays(t, run, []int{1, 2})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServiceLatestConvectiveOutlookNoData(t *testing.T) {
|
func TestServiceLatestConvectiveOutlookNoData(t *testing.T) {
|
||||||
@@ -208,6 +210,7 @@ func TestServiceLatestConvectiveOutlookFiltersByDay(t *testing.T) {
|
|||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
assertOutlookIDs(t, run, []string{"day-2"})
|
assertOutlookIDs(t, run, []string{"day-2"})
|
||||||
|
assertDiscussionDays(t, run, []int{2})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServiceLatestConvectiveOutlookFiltersByOutlookType(t *testing.T) {
|
func TestServiceLatestConvectiveOutlookFiltersByOutlookType(t *testing.T) {
|
||||||
@@ -219,6 +222,7 @@ func TestServiceLatestConvectiveOutlookFiltersByOutlookType(t *testing.T) {
|
|||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
assertOutlookIDs(t, run, []string{"tor-1"})
|
assertOutlookIDs(t, run, []string{"tor-1"})
|
||||||
|
assertDiscussionDays(t, run, []int{1})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServiceLatestConvectiveOutlookFiltersByContainsLocation(t *testing.T) {
|
func TestServiceLatestConvectiveOutlookFiltersByContainsLocation(t *testing.T) {
|
||||||
@@ -230,7 +234,21 @@ func TestServiceLatestConvectiveOutlookFiltersByContainsLocation(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
assertOutlookIDs(t, run, []string{"cat-1", "tor-1"})
|
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) {
|
||||||
@@ -243,6 +261,7 @@ func TestServiceLatestConvectiveOutlookFiltersByActiveAt(t *testing.T) {
|
|||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
assertOutlookIDs(t, run, []string{"cat-1", "tor-1"})
|
assertOutlookIDs(t, run, []string{"cat-1", "tor-1"})
|
||||||
|
assertDiscussionDays(t, run, []int{1})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) {
|
func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) {
|
||||||
@@ -262,6 +281,7 @@ func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) {
|
|||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
assertOutlookIDs(t, run, []string{"cat-1"})
|
assertOutlookIDs(t, run, []string{"cat-1"})
|
||||||
|
assertDiscussionDays(t, run, []int{1})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServiceLatestConvectiveOutlookActiveAtBoundary(t *testing.T) {
|
func TestServiceLatestConvectiveOutlookActiveAtBoundary(t *testing.T) {
|
||||||
@@ -277,12 +297,14 @@ func TestServiceLatestConvectiveOutlookActiveAtBoundary(t *testing.T) {
|
|||||||
t.Fatalf("unexpected validFrom error: %v", err)
|
t.Fatalf("unexpected validFrom error: %v", err)
|
||||||
}
|
}
|
||||||
assertOutlookIDs(t, fromRun, []string{"cat-1"})
|
assertOutlookIDs(t, fromRun, []string{"cat-1"})
|
||||||
|
assertDiscussionDays(t, fromRun, []int{1})
|
||||||
|
|
||||||
toRun, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ActiveAt: &validTo})
|
toRun, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ActiveAt: &validTo})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected validTo error: %v", err)
|
t.Fatalf("unexpected validTo error: %v", err)
|
||||||
}
|
}
|
||||||
assertOutlookIDs(t, toRun, nil)
|
assertOutlookIDs(t, toRun, nil)
|
||||||
|
assertDiscussionDays(t, toRun, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServiceLatestConvectiveOutlookKeepsRunMetadataWithEmptyOutlooks(t *testing.T) {
|
func TestServiceLatestConvectiveOutlookKeepsRunMetadataWithEmptyOutlooks(t *testing.T) {
|
||||||
@@ -306,6 +328,12 @@ func TestServiceLatestConvectiveOutlookKeepsRunMetadataWithEmptyOutlooks(t *test
|
|||||||
if len(run.Outlooks) != 0 {
|
if len(run.Outlooks) != 0 {
|
||||||
t.Fatalf("expected no outlooks, got %+v", run.Outlooks)
|
t.Fatalf("expected no outlooks, got %+v", run.Outlooks)
|
||||||
}
|
}
|
||||||
|
if run.Discussions == nil {
|
||||||
|
t.Fatal("expected empty discussions slice, got nil")
|
||||||
|
}
|
||||||
|
if len(run.Discussions) != 0 {
|
||||||
|
t.Fatalf("expected no discussions, got %+v", run.Discussions)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T) {
|
func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T) {
|
||||||
@@ -325,9 +353,12 @@ func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T)
|
|||||||
*run.Longitude = -99
|
*run.Longitude = -99
|
||||||
*run.IssuedAt = testTime(99)
|
*run.IssuedAt = testTime(99)
|
||||||
*run.Outlooks[0].SeverityRank = 99
|
*run.Outlooks[0].SeverityRank = 99
|
||||||
|
*run.Discussions[0].UpdatedAt = testTime(98)
|
||||||
run.Outlooks[0].Geometry[0] = '{'
|
run.Outlooks[0].Geometry[0] = '{'
|
||||||
run.Outlooks[0].ID = "changed"
|
run.Outlooks[0].ID = "changed"
|
||||||
|
run.Discussions[0].Headline = "changed"
|
||||||
run.Outlooks = run.Outlooks[:1]
|
run.Outlooks = run.Outlooks[:1]
|
||||||
|
run.Discussions = run.Discussions[:1]
|
||||||
|
|
||||||
if *original.Latitude != 38.62 {
|
if *original.Latitude != 38.62 {
|
||||||
t.Fatalf("expected original latitude unchanged, got %v", *original.Latitude)
|
t.Fatalf("expected original latitude unchanged, got %v", *original.Latitude)
|
||||||
@@ -350,6 +381,15 @@ func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T)
|
|||||||
if len(original.Outlooks) != 3 {
|
if len(original.Outlooks) != 3 {
|
||||||
t.Fatalf("expected original outlook slice unchanged, got %d entries", len(original.Outlooks))
|
t.Fatalf("expected original outlook slice unchanged, got %d entries", len(original.Outlooks))
|
||||||
}
|
}
|
||||||
|
if original.Discussions[0].UpdatedAt == nil || !original.Discussions[0].UpdatedAt.Equal(testTime(10)) {
|
||||||
|
t.Fatalf("expected original discussion updatedAt unchanged, got %v", original.Discussions[0].UpdatedAt)
|
||||||
|
}
|
||||||
|
if original.Discussions[0].Headline != "Day 1 headline" {
|
||||||
|
t.Fatalf("expected original discussion headline unchanged, got %q", original.Discussions[0].Headline)
|
||||||
|
}
|
||||||
|
if len(original.Discussions) != 3 {
|
||||||
|
t.Fatalf("expected original discussion slice unchanged, got %d entries", len(original.Discussions))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServicePropagatesErrors(t *testing.T) {
|
func TestServicePropagatesErrors(t *testing.T) {
|
||||||
@@ -376,8 +416,25 @@ func testOutlookRun() *model.WeatherOutlookRun {
|
|||||||
Outlooks: []model.WeatherOutlook{
|
Outlooks: []model.WeatherOutlook{
|
||||||
testOutlook("cat-1", 1, "categorical", true, testTime(12), testTime(18), 5, `["cat"]`),
|
testOutlook("cat-1", 1, "categorical", true, testTime(12), testTime(18), 5, `["cat"]`),
|
||||||
testOutlook("tor-1", 1, "tornado", true, testTime(13), testTime(19), 7, `["tor"]`),
|
testOutlook("tor-1", 1, "tornado", true, testTime(13), testTime(19), 7, `["tor"]`),
|
||||||
testOutlook("day-2", 2, "wind", false, testTime(18), testTime(24), 2, `["wind"]`),
|
testOutlook("day-2", 2, "wind", true, testTime(18), testTime(24), 2, `["wind"]`),
|
||||||
},
|
},
|
||||||
|
Discussions: []model.WeatherOutlookDiscussion{
|
||||||
|
testOutlookDiscussion(1),
|
||||||
|
testOutlookDiscussion(2),
|
||||||
|
testOutlookDiscussion(3),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOutlookDiscussion(day int) model.WeatherOutlookDiscussion {
|
||||||
|
updatedAt := testTime(9 + day)
|
||||||
|
dayText := strconv.Itoa(day)
|
||||||
|
return model.WeatherOutlookDiscussion{
|
||||||
|
Day: day,
|
||||||
|
Headline: "Day " + dayText + " headline",
|
||||||
|
Summary: "Day " + dayText + " summary",
|
||||||
|
Discussion: "Day " + dayText + " discussion",
|
||||||
|
UpdatedAt: &updatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,9 +453,6 @@ func testOutlook(id string, day int, outlookType string, containsLocation bool,
|
|||||||
IssuedAt: validFrom.Add(-time.Hour),
|
IssuedAt: validFrom.Add(-time.Hour),
|
||||||
ExpiresAt: validTo,
|
ExpiresAt: validTo,
|
||||||
Forecaster: "DIAL",
|
Forecaster: "DIAL",
|
||||||
Headline: id + " headline",
|
|
||||||
Summary: id + " summary",
|
|
||||||
Discussion: id + " discussion",
|
|
||||||
SourceURL: "https://example.test/" + id,
|
SourceURL: "https://example.test/" + id,
|
||||||
ImageURL: "https://example.test/" + id + ".png",
|
ImageURL: "https://example.test/" + id + ".png",
|
||||||
ContainsLocation: containsLocation,
|
ContainsLocation: containsLocation,
|
||||||
@@ -424,3 +478,18 @@ func assertOutlookIDs(t *testing.T, run *model.WeatherOutlookRun, want []string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertDiscussionDays(t *testing.T, run *model.WeatherOutlookRun, want []int) {
|
||||||
|
t.Helper()
|
||||||
|
if run == nil {
|
||||||
|
t.Fatal("expected outlook run")
|
||||||
|
}
|
||||||
|
if len(run.Discussions) != len(want) {
|
||||||
|
t.Fatalf("expected discussion days %v, got %+v", want, run.Discussions)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if run.Discussions[i].Day != want[i] {
|
||||||
|
t.Fatalf("expected discussion days %v, got %+v", want, run.Discussions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,16 +15,28 @@ Contains Location: {{$outlook.ContainsLocation}}
|
|||||||
{{- if $outlook.LabelText}}
|
{{- if $outlook.LabelText}}
|
||||||
Label Text: {{$outlook.LabelText}}
|
Label Text: {{$outlook.LabelText}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- if $outlook.Headline}}
|
|
||||||
Headline: {{$outlook.Headline}}
|
|
||||||
{{- end}}
|
|
||||||
{{- if $outlook.Summary}}
|
|
||||||
Summary: {{$outlook.Summary}}
|
|
||||||
{{- end}}
|
|
||||||
{{- if $outlook.SourceURL}}
|
{{- if $outlook.SourceURL}}
|
||||||
Source URL: {{$outlook.SourceURL}}
|
Source URL: {{$outlook.SourceURL}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|
||||||
|
Discussions: {{len .Data.Discussions}}
|
||||||
|
{{- range $i, $discussion := .Data.Discussions}}
|
||||||
|
|
||||||
|
[{{$i}}] Day {{$discussion.Day}} Discussion
|
||||||
|
{{- if $discussion.UpdatedAt}}
|
||||||
|
Updated At: {{$discussion.UpdatedAt}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if $discussion.Headline}}
|
||||||
|
Headline: {{$discussion.Headline}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if $discussion.Summary}}
|
||||||
|
Summary: {{$discussion.Summary}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if $discussion.Discussion}}
|
||||||
|
Discussion: {{$discussion.Discussion}}
|
||||||
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
{{- else -}}
|
{{- else -}}
|
||||||
No convective outlook data available.
|
No convective outlook data available.
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user