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
|
||||
weatherfeeder-owned Postgres tables.
|
||||
weatherfeeder-owned `weather.outlook.v2` Postgres tables.
|
||||
|
||||
Route behavior:
|
||||
|
||||
- `/outlooks/convective` returns the latest run with all stored outlook
|
||||
polygons unless user filters are supplied.
|
||||
- `/outlooks/convective` returns the latest run with stored location-filtered
|
||||
outlook polygons unless user filters are supplied.
|
||||
- `/outlooks/convective/active` adds an active-time filter using the server's
|
||||
current UTC time. Outlooks are active when `validFrom <= now < validTo`.
|
||||
- `/outlooks/convective/location` adds the same active-time filter and
|
||||
`containsLocation=true`.
|
||||
`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
|
||||
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:
|
||||
|
||||
@@ -253,9 +256,12 @@ Query parameters:
|
||||
| `outlookType` | all outlook routes | `categorical`, `tornado`, `hail`, or `wind` |
|
||||
| `containsLocation` | `/outlooks/convective`, `/outlooks/convective/active` | boolean |
|
||||
|
||||
`outlookType` values are normalized case-insensitively. `containsLocation` is
|
||||
rejected on `/outlooks/convective/location` because that route always applies
|
||||
`containsLocation=true`. `precision` and unknown parameters are rejected.
|
||||
`outlookType` values are normalized case-insensitively. Weatherfeeder v2
|
||||
outlooks are already filtered for the configured location, so
|
||||
`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:
|
||||
|
||||
@@ -266,6 +272,7 @@ Run `data` fields:
|
||||
| `asOf` | RFC3339 datetime | required when `data` is not null |
|
||||
| `issuedAt` | RFC3339 datetime | optional |
|
||||
| `outlooks` | array | ordered outlook polygons, possibly empty |
|
||||
| `discussions` | array | ordered day-level discussions, possibly empty |
|
||||
|
||||
Outlook fields:
|
||||
|
||||
@@ -273,17 +280,27 @@ Outlook fields:
|
||||
| --- | --- | --- |
|
||||
| `id`, `provider`, `product`, `outlookType`, `label` | string | required when an outlook is present |
|
||||
| `day` | integer | SPC outlook day |
|
||||
| `labelText`, `forecaster`, `headline`, `summary`, `discussion` | string | optional |
|
||||
| `labelText`, `forecaster` | string | optional |
|
||||
| `severityRank` | integer | optional |
|
||||
| `validFrom`, `validTo`, `issuedAt`, `expiresAt` | RFC3339 datetime | required when an outlook is present |
|
||||
| `sourceUrl`, `imageUrl` | string | optional |
|
||||
| `containsLocation` | boolean | whether the outlook polygon contains the configured location |
|
||||
| `geometry` | GeoJSON | stored outlook geometry |
|
||||
|
||||
Discussion fields:
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `day` | integer | SPC outlook day |
|
||||
| `headline`, `summary`, `discussion` | string | optional |
|
||||
| `updatedAt` | RFC3339 datetime | optional |
|
||||
|
||||
GeoJSON coordinates use standard GeoJSON coordinate order: longitude, then
|
||||
latitude. Timezone conversion applies to run `asOf`, run `issuedAt`, and each
|
||||
outlook's `validFrom`, `validTo`, `issuedAt`, and `expiresAt`. Active filtering
|
||||
compares instants and is not changed by the presentation timezone.
|
||||
outlook's `validFrom`, `validTo`, `issuedAt`, and `expiresAt`, and discussion
|
||||
`updatedAt`. Active filtering compares instants and is not changed by the
|
||||
presentation timezone. Endpoint filters also filter `discussions` to days
|
||||
represented by retained outlooks.
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -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",
|
||||
"containsLocation": true,
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [-90.2, 38.6]
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[-90.6, 38.4],
|
||||
[-90.0, 38.4],
|
||||
[-90.0, 38.8],
|
||||
[-90.6, 38.4]
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"discussions": [
|
||||
{
|
||||
"day": 1,
|
||||
"headline": "Tornado risk near the configured location",
|
||||
"discussion": "SPC tornado outlook discussion text.",
|
||||
"updatedAt": "2026-06-11T17:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
|
||||
`go.mod` depends on:
|
||||
|
||||
- `gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0`
|
||||
- `gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0`
|
||||
|
||||
The repository code also depends on weatherfeeder canonical model types. Table
|
||||
compatibility must match the SQL in `internal/adapters/outbound/postgres`.
|
||||
For convective outlooks, `weatherapi` assumes weatherfeeder's
|
||||
`weather.outlook.v2` table reset has already been applied.
|
||||
|
||||
## Boundary
|
||||
|
||||
@@ -37,7 +39,7 @@ Postgres owns persistence, backup, restore, and availability.
|
||||
| Forecast discussion | `forecast_discussions`, `forecast_discussion_key_messages` |
|
||||
| Weather story run | `weather_story_runs`, `weather_stories` |
|
||||
| Latest weather story | `weather_stories` |
|
||||
| Convective outlook run | `outlook_runs`, `outlooks` |
|
||||
| Convective outlook run | `outlook_runs`, `outlooks`, `outlook_discussions` |
|
||||
|
||||
## Latest Row Selection
|
||||
|
||||
@@ -68,7 +70,8 @@ Child rows are loaded separately and attached in stored order:
|
||||
- forecast periods: `period_index ASC`;
|
||||
- forecast discussion key messages: `message_index ASC`;
|
||||
- weather stories for a run: `story_index ASC`;
|
||||
- outlooks for a run: `outlook_index ASC`.
|
||||
- outlooks for a run: `outlook_index ASC`;
|
||||
- outlook discussions for a run: `discussion_index ASC`.
|
||||
|
||||
## Columns Read
|
||||
|
||||
@@ -160,12 +163,17 @@ routes.
|
||||
|
||||
`outlook_index`, `outlook_id`, `provider`, `product`, `day`, `outlook_type`,
|
||||
`label`, `label_text`, `severity_rank`, `valid_from`, `valid_to`, `issued_at`,
|
||||
`expires_at`, `forecaster`, `headline`, `summary`, `discussion`, `source_url`,
|
||||
`image_url`, `contains_location`, `geometry_json`, and `run_event_id`.
|
||||
`expires_at`, `forecaster`, `source_url`, `image_url`, `contains_location`,
|
||||
`geometry_json`, and `run_event_id`.
|
||||
|
||||
`geometry_json` is copied into response GeoJSON without parsing or
|
||||
reserializing. It must contain valid JSON.
|
||||
|
||||
### `outlook_discussions`
|
||||
|
||||
`discussion_index`, `day`, `headline`, `summary`, `discussion`, `updated_at`,
|
||||
and `run_event_id`.
|
||||
|
||||
## Nullability and Time Assumptions
|
||||
|
||||
The repository scans nullable columns with `sql.Null*` types and maps them to
|
||||
|
||||
@@ -144,6 +144,11 @@ application service:
|
||||
The package variable `outlookNow` exists so endpoint tests can make active and
|
||||
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
|
||||
|
||||
Binder failures become feedapi invalid-parameter responses. Handler service
|
||||
|
||||
@@ -90,7 +90,7 @@ successful responses with `data: null`.
|
||||
`weather_stories`.
|
||||
- `LatestWeatherStory`: latest individual row from `weather_stories`.
|
||||
- `LatestConvectiveOutlookRun`: latest row from `outlook_runs`, then child
|
||||
`outlooks`.
|
||||
`outlooks` and `outlook_discussions`.
|
||||
|
||||
Latest parent rows are selected by descending weather timestamp and
|
||||
`event_emitted_at` where that tie-breaker is available in the query.
|
||||
@@ -105,7 +105,8 @@ Child queries preserve stored order:
|
||||
- forecast periods by `period_index`;
|
||||
- discussion key messages by `message_index`;
|
||||
- weather stories by `story_index`;
|
||||
- outlooks by `outlook_index`.
|
||||
- outlooks by `outlook_index`;
|
||||
- outlook discussions by `discussion_index`.
|
||||
|
||||
Alert references are attached after both alert and reference rows are loaded.
|
||||
References are grouped by alert index and attached to their corresponding alert.
|
||||
|
||||
@@ -118,9 +118,11 @@ response envelope so renderers can produce `data: null`.
|
||||
- Discussions: full or focused payload shapes, section copy, key-message copy,
|
||||
timezone conversion.
|
||||
- Weather stories: run/story copy and timezone conversion.
|
||||
- Convective outlooks: canonical model copy, pointer and geometry copy, and
|
||||
timezone conversion. `units` is accepted by routes but ignored by the
|
||||
presenter because outlook fields are not unit-bearing.
|
||||
- Convective outlooks: canonical model copy, pointer and geometry copy,
|
||||
run-level discussion copy, and timezone conversion for run, outlook, and
|
||||
discussion timestamps. Outlook polygon prose is not handled by the presenter;
|
||||
prose is carried by run-level discussions. `units` is accepted by routes but
|
||||
ignored by the presenter because outlook fields are not unit-bearing.
|
||||
|
||||
## Templates
|
||||
|
||||
|
||||
@@ -91,8 +91,13 @@ build.
|
||||
## Database Dependency
|
||||
|
||||
`weatherapi` expects weatherfeeder-compatible tables for observations, current
|
||||
conditions aggregation, active alerts, forecasts, forecast discussions, and
|
||||
weather stories. It only reads those tables.
|
||||
conditions aggregation, active alerts, forecasts, forecast discussions, weather
|
||||
stories, and convective outlooks. It only reads those tables.
|
||||
|
||||
Convective outlook endpoints require weatherfeeder's `weather.outlook.v2` table
|
||||
shape, including `outlook_runs`, `outlooks`, and `outlook_discussions`. If
|
||||
operators reset or recreate outlook tables during a weatherfeeder upgrade,
|
||||
complete that weatherfeeder-side migration before starting `weatherapi`.
|
||||
|
||||
Operational ownership is split:
|
||||
|
||||
@@ -113,7 +118,7 @@ template named by each endpoint, with templates stored under `templates.base_dir
|
||||
|
||||
The repository includes templates for all implemented endpoint families:
|
||||
observations, current conditions, active alerts, hourly forecasts, narrative
|
||||
forecasts, forecast discussions, and weather stories.
|
||||
forecasts, forecast discussions, weather stories, and convective outlooks.
|
||||
|
||||
If text rendering fails or returns an unsupported-format error, verify:
|
||||
|
||||
|
||||
@@ -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)
|
||||
- [`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)
|
||||
## Verification Record
|
||||
|
||||
Future changes to convective outlook behavior should be proposed in a new
|
||||
roadmap entry before implementation.
|
||||
Completed on 2026-06-12:
|
||||
|
||||
- `go test ./internal/app`
|
||||
- `go test ./internal/adapters/outbound/postgres`
|
||||
- `go test ./internal/adapters/inbound/httpapi/presenter`
|
||||
- `go test ./internal/adapters/inbound/httpapi`
|
||||
- `go test ./...`
|
||||
- `go build ./cmd/weatherapi`
|
||||
|
||||
The database-backed endpoint checklist requires a reachable PostgreSQL database
|
||||
populated by weatherfeeder outlook v2 tables. It was not run in this workspace
|
||||
because no such populated database was available.
|
||||
|
||||
## Summary
|
||||
|
||||
Implement `weatherapi` support for the `weatherfeeder` SPC outlook v2 contract described in `docs/roadmap/outlook.md`.
|
||||
|
||||
This is a compatibility update for the existing convective outlook route family. Do not add or remove public routes. Update dependency, repository reads, app filtering, presenters, templates, tests, and current-behavior docs so `weatherapi` reads `weather.outlook.v2` data from the new weatherfeeder Postgres table shape.
|
||||
|
||||
## Required End State
|
||||
|
||||
- `go.mod` depends on the released `weatherfeeder` version containing `weather.outlook.v2` support.
|
||||
- Existing public routes remain available:
|
||||
- `GET /outlooks/convective`
|
||||
- `GET /outlooks/convective/active`
|
||||
- `GET /outlooks/convective/location`
|
||||
- `weatherapi` remains read-only and does not create, migrate, or repair weatherfeeder tables.
|
||||
- The Postgres repository reads `outlook_runs`, `outlooks`, and `outlook_discussions`.
|
||||
- The repository no longer expects polygon-level `headline`, `summary`, or `discussion` columns on `outlooks`.
|
||||
- Returned `WeatherOutlookRun` values include run-level `Discussions` loaded from `outlook_discussions`.
|
||||
- App-level filtering trims both `Outlooks` and `Discussions` so discussions are present only for days with retained outlooks.
|
||||
- Presenters copy and timezone-convert run-level discussion `updatedAt` values.
|
||||
- JSON/XML/text responses expose run-level `discussions` and do not expose polygon-level outlook prose fields.
|
||||
- `data: null` semantics for no latest run are preserved.
|
||||
- Filtered no-match responses return a non-null run with `outlooks: []` and `discussions: []`.
|
||||
- Public query behavior remains compatible: `format`, `units`, `tz` / `TZ`, `day`, `outlookType`, and allowed `containsLocation` behavior are preserved; `precision` and unknown params remain rejected.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not implement weatherfeeder table creation or migration in weatherapi.
|
||||
- Do not call SPC or any upstream weather provider from weatherapi.
|
||||
- Do not introduce historical browsing or cross-run accumulation in this change.
|
||||
- Do not remove `/outlooks/convective/location`; keep it as an API-compatible active local-outlook route.
|
||||
- Do not keep stale v1 SQL reads for `outlooks.headline`, `outlooks.summary`, or `outlooks.discussion`.
|
||||
- Do not commit a `replace` directive for `weatherfeeder`.
|
||||
- Keep SQL in `*_queries.go`, row DTOs in `*_rows.go`, row mapping in `*_mapper.go`, and request/presentation policy in HTTP/presenter packages.
|
||||
|
||||
## Stage 1: Dependency And Compile Boundary
|
||||
|
||||
### Changes
|
||||
|
||||
1. Update `go.mod` to the released `gitea.maximumdirect.net/ejr/weatherfeeder` version that includes:
|
||||
- `standards.SchemaWeatherOutlookV2`;
|
||||
- `model.WeatherOutlookRun.Discussions`;
|
||||
- `model.WeatherOutlookDiscussion`;
|
||||
- `model.WeatherOutlook` without `Headline`, `Summary`, or `Discussion`.
|
||||
2. Run `go mod tidy`.
|
||||
3. Fix compile errors from removed polygon-level outlook prose fields.
|
||||
4. Search for outlook v1 and polygon prose references:
|
||||
|
||||
```sh
|
||||
rg "SchemaWeatherOutlookV1|weather\.outlook\.v1|\.Headline|\.Summary|\.Discussion|headline|summary|discussion" internal docs templates README.md
|
||||
```
|
||||
|
||||
5. Treat matches carefully:
|
||||
- Keep unrelated alert and forecast-discussion uses.
|
||||
- Remove or update outlook polygon-level prose references.
|
||||
- Replace outlook-specific schema references with v2 where applicable.
|
||||
|
||||
### Expected Files
|
||||
|
||||
- `go.mod`
|
||||
- `go.sum`
|
||||
- `internal/adapters/outbound/postgres/outlooks_rows.go`
|
||||
- `internal/adapters/outbound/postgres/outlooks_mapper.go`
|
||||
- `internal/adapters/outbound/postgres/outlooks_queries.go`
|
||||
- `internal/adapters/outbound/postgres/outlooks_read.go`
|
||||
- `internal/adapters/inbound/httpapi/presenter/outlook.go`
|
||||
- `internal/app/service.go`
|
||||
- outlook-related tests under `internal/app`, `internal/adapters/outbound/postgres`, `internal/adapters/inbound/httpapi`, and presenter tests
|
||||
|
||||
### Verification
|
||||
|
||||
At this stage, run a compile-oriented focused set. It may fail until later stages are complete, but failures should identify remaining v1/prose references.
|
||||
|
||||
```sh
|
||||
go test ./internal/app ./internal/adapters/outbound/postgres ./internal/adapters/inbound/httpapi/presenter
|
||||
```
|
||||
|
||||
## Stage 2: Postgres Query, Row, And Mapper Updates
|
||||
|
||||
### Query Changes
|
||||
|
||||
Update `internal/adapters/outbound/postgres/outlooks_queries.go`.
|
||||
|
||||
Parent query:
|
||||
|
||||
- Keep latest run ordering:
|
||||
|
||||
```sql
|
||||
ORDER BY as_of DESC, event_emitted_at DESC
|
||||
LIMIT 1
|
||||
```
|
||||
|
||||
- Continue selecting parent fields needed by `WeatherOutlookRun`.
|
||||
- Optionally select `discussion_count` for test/sanity visibility, but do not expose it in the API response model.
|
||||
|
||||
Outlook child query:
|
||||
|
||||
- Remove these v1 columns from the `SELECT` list:
|
||||
- `headline`
|
||||
- `summary`
|
||||
- `discussion`
|
||||
- Continue selecting:
|
||||
- `outlook_index`
|
||||
- `outlook_id`
|
||||
- `provider`
|
||||
- `product`
|
||||
- `day`
|
||||
- `outlook_type`
|
||||
- `label`
|
||||
- `label_text`
|
||||
- `severity_rank`
|
||||
- `valid_from`
|
||||
- `valid_to`
|
||||
- `issued_at`
|
||||
- `expires_at`
|
||||
- `forecaster`
|
||||
- `source_url`
|
||||
- `image_url`
|
||||
- `contains_location`
|
||||
- `geometry_json`
|
||||
- Keep `WHERE run_event_id = $1`.
|
||||
- Keep `ORDER BY outlook_index ASC`.
|
||||
|
||||
Discussion child query:
|
||||
|
||||
- Add `queryOutlookDiscussionsForRun`:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
discussion_index,
|
||||
day,
|
||||
headline,
|
||||
summary,
|
||||
discussion,
|
||||
updated_at
|
||||
FROM outlook_discussions
|
||||
WHERE run_event_id = $1
|
||||
ORDER BY discussion_index ASC
|
||||
```
|
||||
|
||||
### Row DTO Changes
|
||||
|
||||
Update `internal/adapters/outbound/postgres/outlooks_rows.go`:
|
||||
|
||||
- Remove `Headline`, `Summary`, and `Discussion` from `outlookRow`.
|
||||
- Add `DiscussionCount` to `outlookRunParentRow` only if selected by parent query.
|
||||
- Add `outlookDiscussionRow`:
|
||||
- `DiscussionIndex int`
|
||||
- `Day int`
|
||||
- `Headline sql.NullString`
|
||||
- `Summary sql.NullString`
|
||||
- `Discussion sql.NullString`
|
||||
- `UpdatedAt sql.NullTime`
|
||||
|
||||
### Mapper Changes
|
||||
|
||||
Update `internal/adapters/outbound/postgres/outlooks_mapper.go`:
|
||||
|
||||
- `mapOutlookRow` maps only v2 polygon fields.
|
||||
- Keep geometry validation with `json.Valid` and return contextual mapper errors for invalid JSON.
|
||||
- Continue copying geometry bytes with `append([]byte(nil), geometry...)`.
|
||||
- Continue normalizing outlook timestamps to UTC.
|
||||
- Add `mapOutlookDiscussionRow` returning `model.WeatherOutlookDiscussion`:
|
||||
- `Day` from row day;
|
||||
- string fields via existing `stringValue` helper;
|
||||
- `UpdatedAt` via existing `timePtr` helper, ensuring UTC normalization.
|
||||
|
||||
### Read Flow Changes
|
||||
|
||||
Update `internal/adapters/outbound/postgres/outlooks_read.go`:
|
||||
|
||||
- `LatestConvectiveOutlookRun` loads parent row as today.
|
||||
- After parent row:
|
||||
- call `loadOutlooks(ctx, row.EventID)`;
|
||||
- call `loadOutlookDiscussions(ctx, row.EventID)`;
|
||||
- attach both to the run.
|
||||
- Add `loadOutlookDiscussions` mirroring `loadOutlooks` style:
|
||||
- query with context;
|
||||
- scan rows;
|
||||
- map rows;
|
||||
- return iteration errors;
|
||||
- wrap query, scan, map, and iteration errors with operation context.
|
||||
- Preserve `nil, nil` on missing parent row.
|
||||
|
||||
### Tests
|
||||
|
||||
Update or add tests in `internal/adapters/outbound/postgres`:
|
||||
|
||||
- `mapOutlookRow` maps v2 polygon fields and no longer expects polygon-level prose.
|
||||
- `mapOutlookRow` rejects invalid `geometry_json`.
|
||||
- nullable label/forecaster/source/image/severity fields map correctly.
|
||||
- `mapOutlookDiscussionRow` maps day/headline/summary/discussion/updatedAt and normalizes time to UTC.
|
||||
- nullable discussion fields map to empty/omitted canonical values.
|
||||
- latest run read loads outlooks in `outlook_index ASC` order.
|
||||
- latest run read loads discussions in `discussion_index ASC` order.
|
||||
- missing latest parent returns `nil, nil`.
|
||||
- query/scan/iteration failures remain context-wrapped.
|
||||
|
||||
### Verification
|
||||
|
||||
```sh
|
||||
go test ./internal/adapters/outbound/postgres
|
||||
```
|
||||
|
||||
## Stage 3: Application Filtering And Copy Semantics
|
||||
|
||||
### Changes
|
||||
|
||||
Update `internal/app/service.go`.
|
||||
|
||||
Clone behavior:
|
||||
|
||||
- `cloneOutlookRun` must deep-copy `Discussions` in addition to `Outlooks`.
|
||||
- Ensure outlook severity pointers, geometry bytes, latitude/longitude pointers, and issuedAt pointers remain copied.
|
||||
- Add a helper such as `cloneOutlookDiscussion` if useful.
|
||||
|
||||
Filtering behavior:
|
||||
|
||||
- Filter `Outlooks` as today using `OutlookFilter`.
|
||||
- After filtering outlooks, filter `Discussions` to only days represented by retained outlooks.
|
||||
- Preserve discussion order from the repository result.
|
||||
- If no outlooks remain, set discussions to an empty slice, not stale unfiltered discussions.
|
||||
- Preserve nil/missing run behavior: repository `nil, nil` still returns `nil, nil`.
|
||||
|
||||
Recommended helper shape:
|
||||
|
||||
```go
|
||||
func filterOutlookDiscussions(discussions []model.WeatherOutlookDiscussion, outlooks []model.WeatherOutlook) []model.WeatherOutlookDiscussion
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Build a set of retained outlook days.
|
||||
- If the set is empty, return `[]model.WeatherOutlookDiscussion{}` when discussions was non-nil or when consistent empty-array output is desired.
|
||||
- Include each discussion only if `discussion.Day` is in the set.
|
||||
- Do not synthesize discussions.
|
||||
|
||||
### Query Compatibility
|
||||
|
||||
Do not change query binder behavior in this stage unless tests reveal compile fallout from the model update. Public query behavior remains:
|
||||
|
||||
- `day` accepted on all outlook routes.
|
||||
- `outlookType` accepted on all outlook routes.
|
||||
- `containsLocation` accepted on `/outlooks/convective` and `/outlooks/convective/active`.
|
||||
- `containsLocation` rejected on `/outlooks/convective/location`.
|
||||
- `format`, `units`, and `tz` / `TZ` accepted.
|
||||
- `precision` and unknown params rejected.
|
||||
|
||||
### Tests
|
||||
|
||||
Update `internal/app/service_test.go`:
|
||||
|
||||
- Delegation test still asserts one repository call.
|
||||
- Day filter retains matching outlooks and only matching day discussions.
|
||||
- Outlook type filter retains discussions only for days with retained outlooks.
|
||||
- Active filter retains discussions only for days with active retained outlooks.
|
||||
- `containsLocation=false` returns an empty outlook/discussion run for v2-style test data.
|
||||
- Filtered no-match result has empty `Outlooks` and empty `Discussions`.
|
||||
- Mutating returned outlooks/discussions does not mutate repository-owned data.
|
||||
- Geometry byte copy and pointer copy behavior remains covered.
|
||||
|
||||
### Verification
|
||||
|
||||
```sh
|
||||
go test ./internal/app
|
||||
```
|
||||
|
||||
## Stage 4: Presenter And Text Template Updates
|
||||
|
||||
### Presenter Changes
|
||||
|
||||
Update `internal/adapters/inbound/httpapi/presenter/outlook.go`:
|
||||
|
||||
- Include `Discussions` when constructing the presented `model.WeatherOutlookRun`.
|
||||
- Convert `WeatherOutlookDiscussion.UpdatedAt` using `inLocationTimePtr`.
|
||||
- Deep-copy discussions so presenter output mutation cannot affect repository/app input.
|
||||
- Remove `Headline`, `Summary`, and `Discussion` assignments from `copyOutlook`.
|
||||
- Continue converting:
|
||||
- run `AsOf`;
|
||||
- run `IssuedAt`;
|
||||
- outlook `ValidFrom`;
|
||||
- outlook `ValidTo`;
|
||||
- outlook `IssuedAt`;
|
||||
- outlook `ExpiresAt`.
|
||||
- Continue preserving geometry copy behavior.
|
||||
- Continue returning `nil` for nil input.
|
||||
|
||||
Recommended helper:
|
||||
|
||||
```go
|
||||
func copyOutlookDiscussion(d model.WeatherOutlookDiscussion, tz *time.Location) model.WeatherOutlookDiscussion
|
||||
```
|
||||
|
||||
### Template Changes
|
||||
|
||||
Update `templates/outlooks_convective.txt.tmpl`:
|
||||
|
||||
- Render run-level discussions from `.Data.Discussions`.
|
||||
- Do not reference outlook-level `.Headline`, `.Summary`, or `.Discussion`.
|
||||
- Keep no-data output when `.Data` is nil.
|
||||
- Keep useful output when `.Data` exists and `.Data.Outlooks` is empty.
|
||||
- Include discussion day/headline/summary/discussion fields in a readable, conditional way.
|
||||
- Avoid printing zero timestamps or `<nil>` values for absent `updatedAt`.
|
||||
|
||||
### Presenter Tests
|
||||
|
||||
Update `internal/adapters/inbound/httpapi/presenter/payload_test.go`:
|
||||
|
||||
- `OutlookRunPayload(nil, ...)` returns nil.
|
||||
- Output includes copied `Discussions`.
|
||||
- Discussion `UpdatedAt` is converted to requested timezone.
|
||||
- Mutating output discussions does not mutate input discussions.
|
||||
- Mutating output geometry does not mutate input geometry.
|
||||
- Outlook timestamps still convert to requested timezone.
|
||||
- Polygon-level prose fields are not expected in output structs/tests.
|
||||
|
||||
### Verification
|
||||
|
||||
```sh
|
||||
go test ./internal/adapters/inbound/httpapi/presenter
|
||||
```
|
||||
|
||||
## Stage 5: HTTP Endpoint Behavior And Tests
|
||||
|
||||
### Handler Behavior
|
||||
|
||||
Keep route registration and handler structure unchanged unless compile updates require mechanical edits.
|
||||
|
||||
Ensure endpoint responses reflect the updated presenter/app behavior:
|
||||
|
||||
- `/outlooks/convective` returns latest run with v2 shape.
|
||||
- `/outlooks/convective/active` applies active filter and trims discussions.
|
||||
- `/outlooks/convective/location` applies active local filter and trims discussions.
|
||||
- `data: null` remains unchanged when no run exists.
|
||||
- Filtered no-match response returns data object with empty outlook and discussion arrays.
|
||||
|
||||
### Endpoint Tests
|
||||
|
||||
Update `internal/adapters/inbound/httpapi/endpoints_test.go`:
|
||||
|
||||
- test fakes build `model.WeatherOutlookRun` with `Discussions`.
|
||||
- JSON success asserts run-level `discussions` are present.
|
||||
- JSON success asserts outlook polygons do not include polygon-level prose.
|
||||
- XML success renders run-level discussions without errors.
|
||||
- Text success renders run-level discussion content.
|
||||
- Timezone test checks conversion of:
|
||||
- run `asOf`;
|
||||
- run `issuedAt`;
|
||||
- outlook times;
|
||||
- discussion `updatedAt`.
|
||||
- Null data test remains unchanged.
|
||||
- Filtered no-match test expects both `outlooks` and `discussions` empty.
|
||||
- Query acceptance still covers `units`, `format`, `tz` / `TZ`, `day`, `outlookType`, and allowed `containsLocation`.
|
||||
- Query rejection still covers `precision`, unknown params, invalid day, invalid outlookType, invalid containsLocation, invalid timezone, conflicting `tz` / `TZ`, and `containsLocation` on `/location`.
|
||||
- Active/location route tests continue using injectable `outlookNow`.
|
||||
|
||||
### Verification
|
||||
|
||||
```sh
|
||||
go test ./internal/adapters/inbound/httpapi
|
||||
```
|
||||
|
||||
## Stage 6: Documentation Updates
|
||||
|
||||
After code behavior is updated, update current-behavior documentation. Do not leave v2 behavior only in roadmap files.
|
||||
|
||||
### Public Docs
|
||||
|
||||
Update `docs/api.md`:
|
||||
|
||||
- State outlook endpoints serve weatherfeeder `weather.outlook.v2` data.
|
||||
- Run fields include `discussions`.
|
||||
- Add discussion field table:
|
||||
- `day`;
|
||||
- `headline`;
|
||||
- `summary`;
|
||||
- `discussion`;
|
||||
- `updatedAt`.
|
||||
- Remove polygon-level `headline`, `summary`, and `discussion` from outlook fields.
|
||||
- State v2 outlooks are already location-filtered by weatherfeeder.
|
||||
- State `containsLocation` is expected to be true for v2 outlooks.
|
||||
- Explain `/outlooks/convective/location` remains an active local-outlook compatibility route under v2.
|
||||
- State endpoint filters also filter `discussions` to days represented by retained outlooks.
|
||||
- State current endpoints use latest-run semantics and do not accumulate historical active outlook rows.
|
||||
- Update examples to show run-level `discussions`.
|
||||
- Ensure examples use valid GeoJSON polygon or multipolygon, not a point, if demonstrating SPC geometry.
|
||||
|
||||
Update `README.md` only if its overview implies all-polygons or old response shape.
|
||||
|
||||
### Integration/Internal Docs
|
||||
|
||||
Update `docs/integrations/weatherfeeder-postgres.md`:
|
||||
|
||||
- Update dependency version.
|
||||
- Table family for convective outlook includes `outlook_runs`, `outlooks`, and `outlook_discussions`.
|
||||
- Latest run selection remains `as_of DESC, event_emitted_at DESC`.
|
||||
- Child ordering includes:
|
||||
- outlooks by `outlook_index ASC`;
|
||||
- outlook discussions by `discussion_index ASC`.
|
||||
- `outlook_runs` includes `discussion_count` if listed.
|
||||
- `outlooks` no longer includes `headline`, `summary`, or `discussion`.
|
||||
- Add `outlook_discussions` columns.
|
||||
- State weatherapi assumes weatherfeeder's outlook v2 table reset has already been applied.
|
||||
|
||||
Update `docs/internal/postgres-repository.md`:
|
||||
|
||||
- `LatestConvectiveOutlookRun` loads parent, outlook children, and discussion children.
|
||||
- Add outlook discussion child ordering.
|
||||
|
||||
Update `docs/internal/presenters.md`:
|
||||
|
||||
- Outlook presenter copies and timezone-converts run-level discussions.
|
||||
- Outlook presenter no longer handles polygon-level prose.
|
||||
|
||||
Update `docs/internal/http-adapter.md`:
|
||||
|
||||
- Outlook filters trim discussions to retained outlook days.
|
||||
- `/location` is retained for compatibility and active local-outlook behavior.
|
||||
|
||||
Update `docs/operations.md` and `docs/troubleshooting.md` only if they mention the old weatherfeeder table shape or should warn operators that weatherapi requires weatherfeeder v2 outlook tables.
|
||||
|
||||
### Roadmap Docs
|
||||
|
||||
After implementation is complete, replace or update `docs/roadmap/outlook.md` and `docs/roadmap/implementation.md` according to repository convention:
|
||||
|
||||
- Either mark the roadmap complete and point to current docs.
|
||||
- Or move any remaining deferred ideas to a future roadmap file.
|
||||
|
||||
Do not leave roadmap files claiming unimplemented work after the implementation has shipped.
|
||||
|
||||
### Documentation Checks
|
||||
|
||||
Run searches for stale outlook v1/current behavior:
|
||||
|
||||
```sh
|
||||
rg "weather\.outlook\.v1|outlooks\.headline|outlooks\.summary|outlooks\.discussion|polygon-level|all stored outlook|all polygons|headline, summary, discussion" docs README.md internal templates
|
||||
```
|
||||
|
||||
Allowed matches:
|
||||
|
||||
- explicit historical/legacy notes, if any;
|
||||
- unrelated alert or forecast discussion prose fields;
|
||||
- roadmap files that are intentionally historical.
|
||||
|
||||
## Stage 7: Full Verification
|
||||
|
||||
Run focused tests:
|
||||
|
||||
```sh
|
||||
go test ./internal/app
|
||||
go test ./internal/adapters/outbound/postgres
|
||||
go test ./internal/adapters/inbound/httpapi/presenter
|
||||
go test ./internal/adapters/inbound/httpapi
|
||||
```
|
||||
|
||||
Run the full suite:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Optional build check:
|
||||
|
||||
```sh
|
||||
go build ./cmd/weatherapi
|
||||
```
|
||||
|
||||
Manual verification against a database populated by weatherfeeder outlook v2 tables:
|
||||
|
||||
```http
|
||||
GET /outlooks/convective
|
||||
GET /outlooks/convective/active
|
||||
GET /outlooks/convective/location
|
||||
GET /outlooks/convective?day=1
|
||||
GET /outlooks/convective?outlookType=tornado
|
||||
GET /outlooks/convective?containsLocation=false
|
||||
GET /outlooks/convective?format=text&tz=America/Chicago
|
||||
```
|
||||
|
||||
Expected manual results:
|
||||
|
||||
- Latest run includes run-level `discussions`.
|
||||
- Active route filters outlooks and discussions together.
|
||||
- Location route returns active local outlooks and remains valid.
|
||||
- `containsLocation=false` returns an empty run for v2 data.
|
||||
- Text format renders discussions without template errors.
|
||||
- XML format renders without errors.
|
||||
- No endpoint queries historical active rows across older runs.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- A weatherfeeder release containing outlook v2 exists before final implementation is committed.
|
||||
- Operators have reset/recreated weatherfeeder outlook tables according to weatherfeeder transition docs.
|
||||
- Existing weatherapi route names remain stable for external consumers.
|
||||
- Keeping `containsLocation` query support on non-location routes is useful backward compatibility even though v2 data should always be local.
|
||||
- Latest-run semantics are correct for current convective outlook endpoints.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. This plan fixes the route, dependency, storage, filtering, presentation, and documentation decisions required to support weatherfeeder outlook v2 while preserving weatherapi's existing public route family.
|
||||
|
||||
@@ -1,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/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/api.md`](../api.md) for public outlook routes, query parameters, and
|
||||
response fields;
|
||||
- [`docs/integrations/weatherfeeder-postgres.md`](../integrations/weatherfeeder-postgres.md)
|
||||
for weatherfeeder-owned table assumptions;
|
||||
- [`docs/internal/postgres-repository.md`](../internal/postgres-repository.md),
|
||||
[`docs/internal/http-adapter.md`](../internal/http-adapter.md), and
|
||||
[`docs/internal/presenters.md`](../internal/presenters.md) for internal
|
||||
behavior.
|
||||
|
||||
Future changes to convective outlook behavior should be proposed in a new
|
||||
roadmap entry before implementation.
|
||||
`docs/roadmap/implementation.md` remains as the implementation checklist and
|
||||
verification record for this compatibility update.
|
||||
|
||||
## Delivered Behavior
|
||||
|
||||
- Existing public routes remain available:
|
||||
- `GET /outlooks/convective`
|
||||
- `GET /outlooks/convective/active`
|
||||
- `GET /outlooks/convective/location`
|
||||
- The Postgres repository reads weatherfeeder outlook v2 tables:
|
||||
`outlook_runs`, `outlooks`, and `outlook_discussions`.
|
||||
- Outlook polygons no longer include prose fields.
|
||||
- Run-level `discussions` are loaded, copied, presented, and timezone-converted.
|
||||
- Application filtering trims `discussions` to days represented by retained
|
||||
outlooks.
|
||||
- Missing latest data still returns `data: null`.
|
||||
- Filtered no-match responses return a run object with `outlooks: []` and
|
||||
`discussions: []`.
|
||||
- `weatherapi` remains read-only and does not create, migrate, or repair
|
||||
weatherfeeder tables.
|
||||
|
||||
@@ -109,7 +109,7 @@ Likely causes:
|
||||
|
||||
- unknown query parameter;
|
||||
- `precision` outside `0` through `2`, or not an integer;
|
||||
- `precision` used on alerts, discussions, or weather stories;
|
||||
- `precision` used on alerts, discussions, weather stories, or outlooks;
|
||||
- `tz` used on observations, current conditions, or alerts;
|
||||
- invalid timezone value;
|
||||
- both `tz` and `TZ` are present with different values.
|
||||
@@ -187,6 +187,10 @@ Safe fix: verify weatherfeeder is running and writing to the same database that
|
||||
`weatherapi` uses as its first configured database. For current conditions,
|
||||
wait for recent observations or inspect weatherfeeder ingestion.
|
||||
|
||||
For convective outlooks, also verify that weatherfeeder has applied its
|
||||
`weather.outlook.v2` table shape and is writing `outlook_runs`, `outlooks`, and
|
||||
`outlook_discussions`.
|
||||
|
||||
## Forecast Day Routes Return Empty `periods`
|
||||
|
||||
Symptom: `/forecast/hourly/today`, `/forecast/hourly/tomorrow`,
|
||||
|
||||
2
go.mod
2
go.mod
@@ -4,7 +4,7 @@ go 1.25.5
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/ejr/feedapi v0.1.0
|
||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0
|
||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0
|
||||
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/go.mod h1:3fIaFFx4ywt0TWbN8DIIBAHJn7ZQUm6PNcceqRgy3bw=
|
||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0 h1:4hRiktLMFS8Arl98j21YYskXT8RHHQGFDPJxOF3CpwU=
|
||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0/go.mod h1:VVtuwrbddWdUu21ovCSSojhH5J9P6kk0/dfnFqC4/Lw=
|
||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0 h1:U3yln3o2rGqfMvWVRwOGgQeqYuqMm+/p0XIRhK8TDUQ=
|
||||
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/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
|
||||
@@ -1314,10 +1314,9 @@ func TestOutlookRoutesJSONSuccess(t *testing.T) {
|
||||
|
||||
var payload struct {
|
||||
Data struct {
|
||||
LocationID string `json:"locationId"`
|
||||
Outlooks []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"outlooks"`
|
||||
LocationID string `json:"locationId"`
|
||||
Outlooks []map[string]any `json:"outlooks"`
|
||||
Discussions []outlookDiscussionCheck `json:"discussions"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
@@ -1326,9 +1325,20 @@ func TestOutlookRoutesJSONSuccess(t *testing.T) {
|
||||
if payload.Data.LocationID != "stl" {
|
||||
t.Fatalf("expected locationId stl, got %q", payload.Data.LocationID)
|
||||
}
|
||||
if len(payload.Data.Outlooks) != 1 || payload.Data.Outlooks[0].ID != "cat-1" {
|
||||
if len(payload.Data.Outlooks) != 1 || payload.Data.Outlooks[0]["id"] != "cat-1" {
|
||||
t.Fatalf("unexpected outlooks payload: %+v", payload.Data.Outlooks)
|
||||
}
|
||||
for _, field := range []string{"headline", "summary", "discussion"} {
|
||||
if _, ok := payload.Data.Outlooks[0][field]; ok {
|
||||
t.Fatalf("expected outlook polygon to omit %s, got %+v", field, payload.Data.Outlooks[0])
|
||||
}
|
||||
}
|
||||
if len(payload.Data.Discussions) != 1 {
|
||||
t.Fatalf("expected one discussion, got %+v", payload.Data.Discussions)
|
||||
}
|
||||
if payload.Data.Discussions[0].Day != 1 || payload.Data.Discussions[0].Headline != "Day 1 headline" {
|
||||
t.Fatalf("unexpected discussions payload: %+v", payload.Data.Discussions)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1369,7 +1379,8 @@ func TestOutlookTextResponseUsesTemplate(t *testing.T) {
|
||||
t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "Convective Outlook") || !strings.Contains(body, "Outlooks: 1") {
|
||||
if !strings.Contains(body, "Convective Outlook") || !strings.Contains(body, "Outlooks: 1") ||
|
||||
!strings.Contains(body, "Discussions: 1") || !strings.Contains(body, "Day 1 discussion") {
|
||||
t.Fatalf("expected outlook text template body, got %q", body)
|
||||
}
|
||||
}
|
||||
@@ -1390,11 +1401,15 @@ func TestOutlookXMLResponseRenders(t *testing.T) {
|
||||
if !strings.Contains(w.Body.String(), "<LocationID>stl</LocationID>") {
|
||||
t.Fatalf("expected outlook XML payload, got %q", w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "<Discussions>") || !strings.Contains(w.Body.String(), "<Headline>Day 1 headline</Headline>") {
|
||||
t.Fatalf("expected outlook XML discussions, got %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
|
||||
run := testOutlookRun()
|
||||
run.Outlooks = []model.WeatherOutlook{}
|
||||
run.Discussions = []model.WeatherOutlookDiscussion{}
|
||||
h := newHandler(t, &fakeService{outlookRun: run}, "/outlooks/convective")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -1407,8 +1422,9 @@ func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
|
||||
|
||||
var payload struct {
|
||||
Data struct {
|
||||
LocationID string `json:"locationId"`
|
||||
Outlooks []model.WeatherOutlook `json:"outlooks"`
|
||||
LocationID string `json:"locationId"`
|
||||
Outlooks []model.WeatherOutlook `json:"outlooks"`
|
||||
Discussions []model.WeatherOutlookDiscussion `json:"discussions"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
@@ -1420,6 +1436,39 @@ func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
|
||||
if payload.Data.Outlooks == nil || len(payload.Data.Outlooks) != 0 {
|
||||
t.Fatalf("expected empty outlooks slice, got %+v", payload.Data.Outlooks)
|
||||
}
|
||||
if payload.Data.Discussions == nil || len(payload.Data.Discussions) != 0 {
|
||||
t.Fatalf("expected empty discussions slice, got %+v", payload.Data.Discussions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutlookTimezoneQuery(t *testing.T) {
|
||||
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, "/outlooks/convective")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?tz=CDT", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var payload outlookTimePayload
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode outlook payload: %v", err)
|
||||
}
|
||||
assertOffsetSeconds(t, payload.Data.AsOf, -5*60*60)
|
||||
assertOffsetSeconds(t, *payload.Data.IssuedAt, -5*60*60)
|
||||
if len(payload.Data.Outlooks) != 1 {
|
||||
t.Fatalf("expected one outlook, got %+v", payload.Data.Outlooks)
|
||||
}
|
||||
assertOffsetSeconds(t, payload.Data.Outlooks[0].ValidFrom, -5*60*60)
|
||||
assertOffsetSeconds(t, payload.Data.Outlooks[0].ValidTo, -5*60*60)
|
||||
assertOffsetSeconds(t, payload.Data.Outlooks[0].IssuedAt, -5*60*60)
|
||||
assertOffsetSeconds(t, payload.Data.Outlooks[0].ExpiresAt, -5*60*60)
|
||||
if len(payload.Data.Discussions) != 1 || payload.Data.Discussions[0].UpdatedAt == nil {
|
||||
t.Fatalf("expected one discussion with updatedAt, got %+v", payload.Data.Discussions)
|
||||
}
|
||||
assertOffsetSeconds(t, *payload.Data.Discussions[0].UpdatedAt, -5*60*60)
|
||||
}
|
||||
|
||||
func TestOutlookQueryParamsConstructFilter(t *testing.T) {
|
||||
@@ -2211,7 +2260,7 @@ func testRenderers(t *testing.T) *render.Registry {
|
||||
"discussion_long_term.txt.tmpl": "Forecast Discussion Long Term",
|
||||
"forecast_hourly.txt.tmpl": "Forecast text",
|
||||
"forecast_narrative.txt.tmpl": "Narrative Forecast",
|
||||
"outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}{{else}}No convective outlook data available.{{end}}",
|
||||
"outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}\nDiscussions: {{len .Data.Discussions}}{{range .Data.Discussions}}\nDiscussion: {{.Discussion}}{{end}}{{else}}No convective outlook data available.{{end}}",
|
||||
"weatherstories.txt.tmpl": "Weather Stories",
|
||||
"weatherstories_latest.txt.tmpl": "Latest Weather Story",
|
||||
"alerts_active.txt.tmpl": "Alerts text",
|
||||
@@ -2250,6 +2299,7 @@ func setOutlookNowForTest(t *testing.T, now time.Time) {
|
||||
|
||||
func testOutlookRun() *model.WeatherOutlookRun {
|
||||
issuedAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
||||
discussionUpdatedAt := issuedAt.Add(30 * time.Minute)
|
||||
return &model.WeatherOutlookRun{
|
||||
LocationID: "stl",
|
||||
LocationName: "St. Louis",
|
||||
@@ -2270,6 +2320,13 @@ func testOutlookRun() *model.WeatherOutlookRun {
|
||||
ContainsLocation: true,
|
||||
Geometry: []byte(`{"type":"Point","coordinates":[-90.2,38.6]}`),
|
||||
}},
|
||||
Discussions: []model.WeatherOutlookDiscussion{{
|
||||
Day: 1,
|
||||
Headline: "Day 1 headline",
|
||||
Summary: "Day 1 summary",
|
||||
Discussion: "Day 1 discussion",
|
||||
UpdatedAt: &discussionUpdatedAt,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2343,6 +2400,27 @@ type discussionFocusedTimePayload struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type outlookDiscussionCheck struct {
|
||||
Day int `json:"day"`
|
||||
Headline string `json:"headline"`
|
||||
}
|
||||
|
||||
type outlookTimePayload struct {
|
||||
Data struct {
|
||||
AsOf time.Time `json:"asOf"`
|
||||
IssuedAt *time.Time `json:"issuedAt"`
|
||||
Outlooks []struct {
|
||||
ValidFrom time.Time `json:"validFrom"`
|
||||
ValidTo time.Time `json:"validTo"`
|
||||
IssuedAt time.Time `json:"issuedAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
} `json:"outlooks"`
|
||||
Discussions []struct {
|
||||
UpdatedAt *time.Time `json:"updatedAt"`
|
||||
} `json:"discussions"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func decodeForecastTimePayload(t *testing.T, w *httptest.ResponseRecorder) forecastTimePayload {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -22,10 +22,14 @@ func OutlookRunPayload(run *model.WeatherOutlookRun, _ Units, tz *time.Location)
|
||||
AsOf: inLocationTime(run.AsOf, tz),
|
||||
IssuedAt: inLocationTimePtr(run.IssuedAt, tz),
|
||||
Outlooks: make([]model.WeatherOutlook, 0, len(run.Outlooks)),
|
||||
Discussions: make([]model.WeatherOutlookDiscussion, 0, len(run.Discussions)),
|
||||
}
|
||||
for _, outlook := range run.Outlooks {
|
||||
out.Outlooks = append(out.Outlooks, copyOutlook(outlook, tz))
|
||||
}
|
||||
for _, discussion := range run.Discussions {
|
||||
out.Discussions = append(out.Discussions, copyOutlookDiscussion(discussion, tz))
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
@@ -44,9 +48,6 @@ func copyOutlook(outlook model.WeatherOutlook, tz *time.Location) model.WeatherO
|
||||
IssuedAt: inLocationTime(outlook.IssuedAt, tz),
|
||||
ExpiresAt: inLocationTime(outlook.ExpiresAt, tz),
|
||||
Forecaster: outlook.Forecaster,
|
||||
Headline: outlook.Headline,
|
||||
Summary: outlook.Summary,
|
||||
Discussion: outlook.Discussion,
|
||||
SourceURL: outlook.SourceURL,
|
||||
ImageURL: outlook.ImageURL,
|
||||
ContainsLocation: outlook.ContainsLocation,
|
||||
@@ -57,6 +58,16 @@ func copyOutlook(outlook model.WeatherOutlook, tz *time.Location) model.WeatherO
|
||||
return out
|
||||
}
|
||||
|
||||
func copyOutlookDiscussion(discussion model.WeatherOutlookDiscussion, tz *time.Location) model.WeatherOutlookDiscussion {
|
||||
return model.WeatherOutlookDiscussion{
|
||||
Day: discussion.Day,
|
||||
Headline: discussion.Headline,
|
||||
Summary: discussion.Summary,
|
||||
Discussion: discussion.Discussion,
|
||||
UpdatedAt: inLocationTimePtr(discussion.UpdatedAt, tz),
|
||||
}
|
||||
}
|
||||
|
||||
func copyIntPtr(v *int) *int {
|
||||
if v == nil {
|
||||
return nil
|
||||
|
||||
@@ -440,6 +440,7 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
||||
loc := time.FixedZone("UTC-05:00", -5*60*60)
|
||||
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
|
||||
issuedAt := asOf.Add(-1 * time.Hour)
|
||||
discussionUpdatedAt := asOf.Add(-30 * time.Minute)
|
||||
severityRank := 5
|
||||
latitude := 38.627123
|
||||
longitude := -90.199456
|
||||
@@ -465,14 +466,18 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
||||
IssuedAt: issuedAt,
|
||||
ExpiresAt: asOf.Add(6 * time.Hour),
|
||||
Forecaster: "DIAL",
|
||||
Headline: "Severe storms possible",
|
||||
Summary: "Scattered severe storms are possible.",
|
||||
Discussion: "Discussion text.",
|
||||
SourceURL: "https://example.test/source",
|
||||
ImageURL: "https://example.test/image.png",
|
||||
ContainsLocation: true,
|
||||
Geometry: geometry,
|
||||
}},
|
||||
Discussions: []model.WeatherOutlookDiscussion{{
|
||||
Day: 1,
|
||||
Headline: "Severe storms possible",
|
||||
Summary: "Scattered severe storms are possible.",
|
||||
Discussion: "Discussion text.",
|
||||
UpdatedAt: &discussionUpdatedAt,
|
||||
}},
|
||||
}
|
||||
|
||||
payload := OutlookRunPayload(run, UnitsUS, loc)
|
||||
@@ -489,9 +494,15 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
||||
if len(out.Outlooks) != 1 {
|
||||
t.Fatalf("expected one outlook, got %d", len(out.Outlooks))
|
||||
}
|
||||
if len(out.Discussions) != 1 {
|
||||
t.Fatalf("expected one discussion, got %d", len(out.Discussions))
|
||||
}
|
||||
if out.Outlooks[0].SeverityRank == run.Outlooks[0].SeverityRank {
|
||||
t.Fatalf("expected severity rank pointer to be copied")
|
||||
}
|
||||
if out.Discussions[0].UpdatedAt == run.Discussions[0].UpdatedAt {
|
||||
t.Fatalf("expected discussion updatedAt pointer to be copied")
|
||||
}
|
||||
if &out.Outlooks[0].Geometry[0] == &run.Outlooks[0].Geometry[0] {
|
||||
t.Fatalf("expected geometry bytes to be copied")
|
||||
}
|
||||
@@ -502,6 +513,7 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
||||
assertOffsetSeconds(t, out.Outlooks[0].ValidTo, -5*60*60)
|
||||
assertOffsetSeconds(t, out.Outlooks[0].IssuedAt, -5*60*60)
|
||||
assertOffsetSeconds(t, out.Outlooks[0].ExpiresAt, -5*60*60)
|
||||
assertOffsetSeconds(t, *out.Discussions[0].UpdatedAt, -5*60*60)
|
||||
if !out.AsOf.UTC().Equal(asOf) || !out.Outlooks[0].ValidFrom.UTC().Equal(asOf) {
|
||||
t.Fatalf("expected timezone conversion to preserve instants")
|
||||
}
|
||||
@@ -515,12 +527,19 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
||||
if string(out.Outlooks[0].Geometry) != string(geometry) {
|
||||
t.Fatalf("expected geometry bytes preserved, got %s", out.Outlooks[0].Geometry)
|
||||
}
|
||||
if out.Discussions[0].Day != 1 || out.Discussions[0].Headline != "Severe storms possible" ||
|
||||
out.Discussions[0].Summary != "Scattered severe storms are possible." ||
|
||||
out.Discussions[0].Discussion != "Discussion text." {
|
||||
t.Fatalf("expected discussion fields preserved, got %+v", out.Discussions[0])
|
||||
}
|
||||
|
||||
*out.Latitude = 99
|
||||
*out.Longitude = -99
|
||||
*out.IssuedAt = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
*out.Outlooks[0].SeverityRank = 99
|
||||
*out.Discussions[0].UpdatedAt = time.Date(2031, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
out.Outlooks[0].Geometry[0] = '['
|
||||
out.Discussions[0].Headline = "changed"
|
||||
if *run.Latitude != latitude || *run.Longitude != longitude || !run.IssuedAt.Equal(issuedAt) {
|
||||
t.Fatalf("expected source run pointers not to mutate")
|
||||
}
|
||||
@@ -530,8 +549,15 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
||||
if string(run.Outlooks[0].Geometry) != string(geometry) {
|
||||
t.Fatalf("expected source geometry not to mutate, got %s", run.Outlooks[0].Geometry)
|
||||
}
|
||||
if !run.Discussions[0].UpdatedAt.Equal(discussionUpdatedAt) {
|
||||
t.Fatalf("expected source discussion updatedAt not to mutate, got %v", run.Discussions[0].UpdatedAt)
|
||||
}
|
||||
if run.Discussions[0].Headline != "Severe storms possible" {
|
||||
t.Fatalf("expected source discussion headline not to mutate, got %q", run.Discussions[0].Headline)
|
||||
}
|
||||
assertOffsetSeconds(t, run.AsOf, 0)
|
||||
assertOffsetSeconds(t, run.Outlooks[0].ValidFrom, 0)
|
||||
assertOffsetSeconds(t, *run.Discussions[0].UpdatedAt, 0)
|
||||
}
|
||||
|
||||
func float64Ptr(v float64) *float64 {
|
||||
|
||||
@@ -41,9 +41,6 @@ func mapOutlookRow(row outlookRow) (model.WeatherOutlook, error) {
|
||||
IssuedAt: row.IssuedAt.UTC(),
|
||||
ExpiresAt: row.ExpiresAt.UTC(),
|
||||
Forecaster: stringValue(row.Forecaster),
|
||||
Headline: stringValue(row.Headline),
|
||||
Summary: stringValue(row.Summary),
|
||||
Discussion: stringValue(row.Discussion),
|
||||
SourceURL: stringValue(row.SourceURL),
|
||||
ImageURL: stringValue(row.ImageURL),
|
||||
ContainsLocation: row.ContainsLocation,
|
||||
@@ -51,6 +48,16 @@ func mapOutlookRow(row outlookRow) (model.WeatherOutlook, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mapOutlookDiscussionRow(row outlookDiscussionRow) model.WeatherOutlookDiscussion {
|
||||
return model.WeatherOutlookDiscussion{
|
||||
Day: row.Day,
|
||||
Headline: stringValue(row.Headline),
|
||||
Summary: stringValue(row.Summary),
|
||||
Discussion: stringValue(row.Discussion),
|
||||
UpdatedAt: timePtr(row.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(v sql.NullInt64) *int {
|
||||
if !v.Valid {
|
||||
return nil
|
||||
|
||||
@@ -79,9 +79,6 @@ func TestMapOutlookRowMapsFields(t *testing.T) {
|
||||
IssuedAt: issuedAt,
|
||||
ExpiresAt: expiresAt,
|
||||
Forecaster: sql.NullString{String: "DIAL", Valid: true},
|
||||
Headline: sql.NullString{String: "Severe storms possible", Valid: true},
|
||||
Summary: sql.NullString{String: "Scattered severe storms are possible.", Valid: true},
|
||||
Discussion: sql.NullString{String: "Discussion text.", Valid: true},
|
||||
SourceURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.html", Valid: true},
|
||||
ImageURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.gif", Valid: true},
|
||||
ContainsLocation: true,
|
||||
@@ -103,8 +100,8 @@ func TestMapOutlookRowMapsFields(t *testing.T) {
|
||||
if outlook.SeverityRank == nil || *outlook.SeverityRank != int(severityRank) {
|
||||
t.Fatalf("expected severity rank %d, got %v", severityRank, outlook.SeverityRank)
|
||||
}
|
||||
if outlook.Forecaster != "DIAL" || outlook.Headline == "" || outlook.Summary == "" || outlook.Discussion == "" {
|
||||
t.Fatalf("unexpected text fields: %+v", outlook)
|
||||
if outlook.Forecaster != "DIAL" {
|
||||
t.Fatalf("unexpected forecaster: %+v", outlook)
|
||||
}
|
||||
if outlook.SourceURL == "" || outlook.ImageURL == "" {
|
||||
t.Fatalf("expected source and image URLs: %+v", outlook)
|
||||
@@ -142,8 +139,7 @@ func TestMapOutlookRowMissingOptionals(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if outlook.LabelText != "" || outlook.Forecaster != "" || outlook.Headline != "" ||
|
||||
outlook.Summary != "" || outlook.Discussion != "" || outlook.SourceURL != "" || outlook.ImageURL != "" {
|
||||
if outlook.LabelText != "" || outlook.Forecaster != "" || outlook.SourceURL != "" || outlook.ImageURL != "" {
|
||||
t.Fatalf("expected optional strings to map to empty values, got %+v", outlook)
|
||||
}
|
||||
if outlook.SeverityRank != nil {
|
||||
@@ -172,3 +168,49 @@ func TestMapOutlookRowRejectsInvalidGeometry(t *testing.T) {
|
||||
t.Fatal("expected invalid geometry error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapOutlookDiscussionRowMapsFields(t *testing.T) {
|
||||
updatedAt := time.Date(2026, 6, 11, 7, 30, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||
|
||||
discussion := mapOutlookDiscussionRow(outlookDiscussionRow{
|
||||
DiscussionIndex: 2,
|
||||
Day: 2,
|
||||
Headline: sql.NullString{String: "Severe storms possible", Valid: true},
|
||||
Summary: sql.NullString{String: "Scattered severe storms are possible.", Valid: true},
|
||||
Discussion: sql.NullString{String: "Discussion text.", Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: updatedAt, Valid: true},
|
||||
})
|
||||
|
||||
if discussion.Day != 2 {
|
||||
t.Fatalf("expected day 2, got %d", discussion.Day)
|
||||
}
|
||||
if discussion.Headline != "Severe storms possible" {
|
||||
t.Fatalf("unexpected headline: %q", discussion.Headline)
|
||||
}
|
||||
if discussion.Summary != "Scattered severe storms are possible." {
|
||||
t.Fatalf("unexpected summary: %q", discussion.Summary)
|
||||
}
|
||||
if discussion.Discussion != "Discussion text." {
|
||||
t.Fatalf("unexpected discussion: %q", discussion.Discussion)
|
||||
}
|
||||
if discussion.UpdatedAt == nil || discussion.UpdatedAt.Location().String() != "UTC" {
|
||||
t.Fatalf("expected updatedAt UTC pointer, got %v", discussion.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapOutlookDiscussionRowMissingOptionals(t *testing.T) {
|
||||
discussion := mapOutlookDiscussionRow(outlookDiscussionRow{
|
||||
DiscussionIndex: 1,
|
||||
Day: 1,
|
||||
})
|
||||
|
||||
if discussion.Day != 1 {
|
||||
t.Fatalf("expected day 1, got %d", discussion.Day)
|
||||
}
|
||||
if discussion.Headline != "" || discussion.Summary != "" || discussion.Discussion != "" {
|
||||
t.Fatalf("expected empty optional strings, got %+v", discussion)
|
||||
}
|
||||
if discussion.UpdatedAt != nil {
|
||||
t.Fatalf("expected nil updatedAt, got %v", discussion.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +32,6 @@ SELECT
|
||||
issued_at,
|
||||
expires_at,
|
||||
forecaster,
|
||||
headline,
|
||||
summary,
|
||||
discussion,
|
||||
source_url,
|
||||
image_url,
|
||||
contains_location,
|
||||
@@ -42,4 +39,16 @@ SELECT
|
||||
FROM outlooks
|
||||
WHERE run_event_id = $1
|
||||
ORDER BY outlook_index ASC`
|
||||
|
||||
queryOutlookDiscussionsForRun = `
|
||||
SELECT
|
||||
discussion_index,
|
||||
day,
|
||||
headline,
|
||||
summary,
|
||||
discussion,
|
||||
updated_at
|
||||
FROM outlook_discussions
|
||||
WHERE run_event_id = $1
|
||||
ORDER BY discussion_index ASC`
|
||||
)
|
||||
|
||||
@@ -40,6 +40,12 @@ func (r *Repository) LatestConvectiveOutlookRun(ctx context.Context) (*model.Wea
|
||||
}
|
||||
run.Outlooks = outlooks
|
||||
|
||||
discussions, err := r.loadOutlookDiscussions(ctx, row.EventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
run.Discussions = discussions
|
||||
|
||||
return &run, nil
|
||||
}
|
||||
|
||||
@@ -68,9 +74,6 @@ func (r *Repository) loadOutlooks(ctx context.Context, eventID string) ([]model.
|
||||
&row.IssuedAt,
|
||||
&row.ExpiresAt,
|
||||
&row.Forecaster,
|
||||
&row.Headline,
|
||||
&row.Summary,
|
||||
&row.Discussion,
|
||||
&row.SourceURL,
|
||||
&row.ImageURL,
|
||||
&row.ContainsLocation,
|
||||
@@ -90,3 +93,32 @@ func (r *Repository) loadOutlooks(ctx context.Context, eventID string) ([]model.
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repository) loadOutlookDiscussions(ctx context.Context, eventID string) ([]model.WeatherOutlookDiscussion, error) {
|
||||
rows, err := r.db.QueryContext(ctx, queryOutlookDiscussionsForRun, eventID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query outlook discussions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]model.WeatherOutlookDiscussion, 0)
|
||||
for rows.Next() {
|
||||
var row outlookDiscussionRow
|
||||
if err := rows.Scan(
|
||||
&row.DiscussionIndex,
|
||||
&row.Day,
|
||||
&row.Headline,
|
||||
&row.Summary,
|
||||
&row.Discussion,
|
||||
&row.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan outlook discussion row: %w", err)
|
||||
}
|
||||
|
||||
out = append(out, mapOutlookDiscussionRow(row))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate outlook discussion rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
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
|
||||
ExpiresAt time.Time
|
||||
Forecaster sql.NullString
|
||||
Headline sql.NullString
|
||||
Summary sql.NullString
|
||||
Discussion sql.NullString
|
||||
SourceURL sql.NullString
|
||||
ImageURL sql.NullString
|
||||
ContainsLocation bool
|
||||
GeometryJSON string
|
||||
}
|
||||
|
||||
type outlookDiscussionRow struct {
|
||||
DiscussionIndex int
|
||||
Day int
|
||||
Headline sql.NullString
|
||||
Summary sql.NullString
|
||||
Discussion sql.NullString
|
||||
UpdatedAt sql.NullTime
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ func (s *Service) LatestConvectiveOutlook(ctx context.Context, filter OutlookFil
|
||||
}
|
||||
}
|
||||
out.Outlooks = outlooks
|
||||
out.Discussions = filterOutlookDiscussions(out.Discussions, out.Outlooks)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -123,6 +124,12 @@ func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
|
||||
out.Outlooks[i] = cloneOutlook(run.Outlooks[i])
|
||||
}
|
||||
}
|
||||
if run.Discussions != nil {
|
||||
out.Discussions = make([]model.WeatherOutlookDiscussion, len(run.Discussions))
|
||||
for i := range run.Discussions {
|
||||
out.Discussions[i] = cloneOutlookDiscussion(run.Discussions[i])
|
||||
}
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
@@ -135,6 +142,31 @@ func cloneOutlook(outlook model.WeatherOutlook) model.WeatherOutlook {
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneOutlookDiscussion(discussion model.WeatherOutlookDiscussion) model.WeatherOutlookDiscussion {
|
||||
out := discussion
|
||||
out.UpdatedAt = copyTime(discussion.UpdatedAt)
|
||||
return out
|
||||
}
|
||||
|
||||
func filterOutlookDiscussions(discussions []model.WeatherOutlookDiscussion, outlooks []model.WeatherOutlook) []model.WeatherOutlookDiscussion {
|
||||
if len(outlooks) == 0 {
|
||||
return []model.WeatherOutlookDiscussion{}
|
||||
}
|
||||
|
||||
retainedDays := make(map[int]struct{}, len(outlooks))
|
||||
for _, outlook := range outlooks {
|
||||
retainedDays[outlook.Day] = struct{}{}
|
||||
}
|
||||
|
||||
out := discussions[:0]
|
||||
for _, discussion := range discussions {
|
||||
if _, ok := retainedDays[discussion.Day]; ok {
|
||||
out = append(out, discussion)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func copyFloat64(value *float64) *float64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
|
||||
@@ -5,6 +5,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -131,6 +132,7 @@ func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
|
||||
if run == nil || run.LocationID != "stl" {
|
||||
t.Fatalf("unexpected outlook run: %+v", run)
|
||||
}
|
||||
assertDiscussionDays(t, run, []int{1, 2})
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookNoData(t *testing.T) {
|
||||
@@ -208,6 +210,7 @@ func TestServiceLatestConvectiveOutlookFiltersByDay(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, run, []string{"day-2"})
|
||||
assertDiscussionDays(t, run, []int{2})
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookFiltersByOutlookType(t *testing.T) {
|
||||
@@ -219,6 +222,7 @@ func TestServiceLatestConvectiveOutlookFiltersByOutlookType(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, run, []string{"tor-1"})
|
||||
assertDiscussionDays(t, run, []int{1})
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookFiltersByContainsLocation(t *testing.T) {
|
||||
@@ -230,7 +234,21 @@ func TestServiceLatestConvectiveOutlookFiltersByContainsLocation(t *testing.T) {
|
||||
if err != nil {
|
||||
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) {
|
||||
@@ -243,6 +261,7 @@ func TestServiceLatestConvectiveOutlookFiltersByActiveAt(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, run, []string{"cat-1", "tor-1"})
|
||||
assertDiscussionDays(t, run, []int{1})
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) {
|
||||
@@ -262,6 +281,7 @@ func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, run, []string{"cat-1"})
|
||||
assertDiscussionDays(t, run, []int{1})
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookActiveAtBoundary(t *testing.T) {
|
||||
@@ -277,12 +297,14 @@ func TestServiceLatestConvectiveOutlookActiveAtBoundary(t *testing.T) {
|
||||
t.Fatalf("unexpected validFrom error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, fromRun, []string{"cat-1"})
|
||||
assertDiscussionDays(t, fromRun, []int{1})
|
||||
|
||||
toRun, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ActiveAt: &validTo})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected validTo error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, toRun, nil)
|
||||
assertDiscussionDays(t, toRun, nil)
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookKeepsRunMetadataWithEmptyOutlooks(t *testing.T) {
|
||||
@@ -306,6 +328,12 @@ func TestServiceLatestConvectiveOutlookKeepsRunMetadataWithEmptyOutlooks(t *test
|
||||
if len(run.Outlooks) != 0 {
|
||||
t.Fatalf("expected no outlooks, got %+v", run.Outlooks)
|
||||
}
|
||||
if run.Discussions == nil {
|
||||
t.Fatal("expected empty discussions slice, got nil")
|
||||
}
|
||||
if len(run.Discussions) != 0 {
|
||||
t.Fatalf("expected no discussions, got %+v", run.Discussions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T) {
|
||||
@@ -325,9 +353,12 @@ func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T)
|
||||
*run.Longitude = -99
|
||||
*run.IssuedAt = testTime(99)
|
||||
*run.Outlooks[0].SeverityRank = 99
|
||||
*run.Discussions[0].UpdatedAt = testTime(98)
|
||||
run.Outlooks[0].Geometry[0] = '{'
|
||||
run.Outlooks[0].ID = "changed"
|
||||
run.Discussions[0].Headline = "changed"
|
||||
run.Outlooks = run.Outlooks[:1]
|
||||
run.Discussions = run.Discussions[:1]
|
||||
|
||||
if *original.Latitude != 38.62 {
|
||||
t.Fatalf("expected original latitude unchanged, got %v", *original.Latitude)
|
||||
@@ -350,6 +381,15 @@ func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T)
|
||||
if len(original.Outlooks) != 3 {
|
||||
t.Fatalf("expected original outlook slice unchanged, got %d entries", len(original.Outlooks))
|
||||
}
|
||||
if original.Discussions[0].UpdatedAt == nil || !original.Discussions[0].UpdatedAt.Equal(testTime(10)) {
|
||||
t.Fatalf("expected original discussion updatedAt unchanged, got %v", original.Discussions[0].UpdatedAt)
|
||||
}
|
||||
if original.Discussions[0].Headline != "Day 1 headline" {
|
||||
t.Fatalf("expected original discussion headline unchanged, got %q", original.Discussions[0].Headline)
|
||||
}
|
||||
if len(original.Discussions) != 3 {
|
||||
t.Fatalf("expected original discussion slice unchanged, got %d entries", len(original.Discussions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePropagatesErrors(t *testing.T) {
|
||||
@@ -376,8 +416,25 @@ func testOutlookRun() *model.WeatherOutlookRun {
|
||||
Outlooks: []model.WeatherOutlook{
|
||||
testOutlook("cat-1", 1, "categorical", true, testTime(12), testTime(18), 5, `["cat"]`),
|
||||
testOutlook("tor-1", 1, "tornado", true, testTime(13), testTime(19), 7, `["tor"]`),
|
||||
testOutlook("day-2", 2, "wind", false, testTime(18), testTime(24), 2, `["wind"]`),
|
||||
testOutlook("day-2", 2, "wind", true, testTime(18), testTime(24), 2, `["wind"]`),
|
||||
},
|
||||
Discussions: []model.WeatherOutlookDiscussion{
|
||||
testOutlookDiscussion(1),
|
||||
testOutlookDiscussion(2),
|
||||
testOutlookDiscussion(3),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testOutlookDiscussion(day int) model.WeatherOutlookDiscussion {
|
||||
updatedAt := testTime(9 + day)
|
||||
dayText := strconv.Itoa(day)
|
||||
return model.WeatherOutlookDiscussion{
|
||||
Day: day,
|
||||
Headline: "Day " + dayText + " headline",
|
||||
Summary: "Day " + dayText + " summary",
|
||||
Discussion: "Day " + dayText + " discussion",
|
||||
UpdatedAt: &updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,9 +453,6 @@ func testOutlook(id string, day int, outlookType string, containsLocation bool,
|
||||
IssuedAt: validFrom.Add(-time.Hour),
|
||||
ExpiresAt: validTo,
|
||||
Forecaster: "DIAL",
|
||||
Headline: id + " headline",
|
||||
Summary: id + " summary",
|
||||
Discussion: id + " discussion",
|
||||
SourceURL: "https://example.test/" + id,
|
||||
ImageURL: "https://example.test/" + id + ".png",
|
||||
ContainsLocation: containsLocation,
|
||||
@@ -424,3 +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}}
|
||||
Label Text: {{$outlook.LabelText}}
|
||||
{{- end}}
|
||||
{{- if $outlook.Headline}}
|
||||
Headline: {{$outlook.Headline}}
|
||||
{{- end}}
|
||||
{{- if $outlook.Summary}}
|
||||
Summary: {{$outlook.Summary}}
|
||||
{{- end}}
|
||||
{{- if $outlook.SourceURL}}
|
||||
Source URL: {{$outlook.SourceURL}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
Discussions: {{len .Data.Discussions}}
|
||||
{{- range $i, $discussion := .Data.Discussions}}
|
||||
|
||||
[{{$i}}] Day {{$discussion.Day}} Discussion
|
||||
{{- if $discussion.UpdatedAt}}
|
||||
Updated At: {{$discussion.UpdatedAt}}
|
||||
{{- end}}
|
||||
{{- if $discussion.Headline}}
|
||||
Headline: {{$discussion.Headline}}
|
||||
{{- end}}
|
||||
{{- if $discussion.Summary}}
|
||||
Summary: {{$discussion.Summary}}
|
||||
{{- end}}
|
||||
{{- if $discussion.Discussion}}
|
||||
Discussion: {{$discussion.Discussion}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- else -}}
|
||||
No convective outlook data available.
|
||||
{{- end}}
|
||||
|
||||
Reference in New Issue
Block a user