Compare commits
10 Commits
v0.4.0
...
90abc536dd
| Author | SHA1 | Date | |
|---|---|---|---|
| 90abc536dd | |||
| 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,230 @@
|
|||||||
# SPC Convective Outlook API Roadmap
|
# Active Alert Filtering Cleanup
|
||||||
|
|
||||||
There are no active roadmap items for the SPC convective outlook API.
|
## Summary
|
||||||
|
|
||||||
Current behavior is documented in:
|
Fix `/alerts/active` so it returns alerts that are active at request time, not merely every alert from the latest stored alert snapshot.
|
||||||
|
|
||||||
- [`docs/api.md`](../api.md)
|
The current implementation reads the latest `alert_runs` parent row, loads all child `alerts`, and presents the run unchanged. This can expose expired alerts when the latest persisted weatherfeeder snapshot is stale or when a snapshot contains alerts that later expire before the next successful ingestion cycle.
|
||||||
- [`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
|
This cleanup preserves the existing route, response envelope, repository contract, and weatherfeeder table ownership. The change should be implemented as app-layer filtering over the latest stored snapshot, following the same architectural pattern already used by convective outlook active filtering.
|
||||||
roadmap entry before implementation.
|
|
||||||
|
## Current Behavior To Correct
|
||||||
|
|
||||||
|
- `GET /alerts/active` calls `LatestAlertRun(ctx)`.
|
||||||
|
- `LatestAlertRun(ctx)` returns the latest stored alert snapshot from Postgres.
|
||||||
|
- All child alerts for that run are returned unchanged.
|
||||||
|
- The endpoint does not compare `effective`, `onset`, `expires`, `status`, or `messageType` to the request time.
|
||||||
|
- Expired alerts can therefore appear under an endpoint named `/alerts/active`.
|
||||||
|
|
||||||
|
## Target Behavior
|
||||||
|
|
||||||
|
- `GET /alerts/active` returns the latest stored alert run with `alerts` filtered to items active at request time.
|
||||||
|
- Missing latest alert run still returns `{ "data": null }`.
|
||||||
|
- A latest run with no currently active alerts returns a non-null run object with `alerts: []`.
|
||||||
|
- The repository remains a latest-snapshot reader and does not own active-time policy.
|
||||||
|
- The app service owns alert active filtering policy.
|
||||||
|
- The HTTP adapter supplies the request-time instant using an injectable clock for tests.
|
||||||
|
- Presentation remains responsible only for response shape, units no-op behavior, and rendering.
|
||||||
|
|
||||||
|
## Active Alert Policy
|
||||||
|
|
||||||
|
Use a single app-layer predicate for determining whether an alert is active at an instant `activeAt`.
|
||||||
|
|
||||||
|
An alert is active when all of the following are true:
|
||||||
|
|
||||||
|
- `messageType` is not `Cancel`, case-insensitive after trimming.
|
||||||
|
- `effective` is absent or `effective <= activeAt`.
|
||||||
|
- `expires` is absent or `activeAt < expires`.
|
||||||
|
|
||||||
|
Additional policy notes:
|
||||||
|
|
||||||
|
- Do not use `onset` as a required active boundary. `onset` can describe hazard onset and may be later than alert effective time; using it as a hard lower bound could hide valid watches, warnings, or advisories that are already in effect from an alerting perspective.
|
||||||
|
- Do not filter by `status` in the first cleanup unless existing model/test data proves a specific non-active status must be excluded. NWS active feeds commonly use `Actual`; persisted historical snapshots may include other values, but time and cancellation policy are the high-confidence active criteria.
|
||||||
|
- Treat nil `expires` as active if the other criteria pass. This preserves data when an upstream alert omits an expiration, while still allowing future tightening if real data shows nil expiration should be suppressed.
|
||||||
|
- Preserve input alert order after filtering.
|
||||||
|
- Preserve run metadata such as `asOf`, location fields, latitude, and longitude even when all alerts are filtered out.
|
||||||
|
|
||||||
|
## Public API Impact
|
||||||
|
|
||||||
|
- Route remains `GET /alerts/active`.
|
||||||
|
- Supported query parameters remain `format` and `units`.
|
||||||
|
- `precision`, `tz` / `TZ`, and unknown query parameters remain rejected unless a separate roadmap explicitly changes alert query support.
|
||||||
|
- JSON/XML/text format support remains unchanged.
|
||||||
|
- Response envelope remains `{ "data": ... }`.
|
||||||
|
- No Postgres schema or weatherfeeder migration is required.
|
||||||
|
|
||||||
|
## Stage 1: App Service Filtering
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- Keep `app.Repository.LatestAlertRun(ctx)` unchanged.
|
||||||
|
- Add an app-level alert read use case, either:
|
||||||
|
- `LatestActiveAlertRun(ctx context.Context, activeAt time.Time) (*model.WeatherAlertRun, error)`, or
|
||||||
|
- `LatestAlerts(ctx context.Context, filter AlertFilter) (*model.WeatherAlertRun, error)` with `AlertFilter.ActiveAt`.
|
||||||
|
- Prefer the first option unless another alert route is being added at the same time; it is narrower and clearer for the existing API.
|
||||||
|
- Implement the new service method by:
|
||||||
|
- reading `s.repo.LatestAlertRun(ctx)`;
|
||||||
|
- returning `nil, nil` when no run exists;
|
||||||
|
- cloning the run before mutation;
|
||||||
|
- filtering cloned `Alerts` with the active predicate;
|
||||||
|
- returning the cloned run.
|
||||||
|
- Add package-local helpers for:
|
||||||
|
- cloning `WeatherAlertRun`;
|
||||||
|
- cloning `WeatherAlert` values deeply enough to avoid mutating repository-owned slices;
|
||||||
|
- copying `References` slices;
|
||||||
|
- evaluating `isActiveAlert(alert, activeAt)`.
|
||||||
|
|
||||||
|
### Clone Requirements
|
||||||
|
|
||||||
|
The clone must protect repository-returned data from service mutation:
|
||||||
|
|
||||||
|
- allocate a new `Alerts` slice;
|
||||||
|
- allocate new `References` slices for each alert;
|
||||||
|
- copy pointer time fields only if the model uses pointer fields for alert timestamps;
|
||||||
|
- preserve string, boolean, enum, and scalar values exactly.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Add app tests covering:
|
||||||
|
|
||||||
|
- repository delegation and error propagation;
|
||||||
|
- `nil, nil` when the repository has no latest alert run;
|
||||||
|
- expired alerts are removed when `activeAt >= expires`;
|
||||||
|
- alerts expiring exactly at `activeAt` are inactive;
|
||||||
|
- alerts effective exactly at `activeAt` are active;
|
||||||
|
- future-effective alerts are inactive;
|
||||||
|
- missing `effective` does not make an otherwise valid alert inactive;
|
||||||
|
- missing `expires` does not make an otherwise valid alert inactive;
|
||||||
|
- `messageType=Cancel` is excluded case-insensitively;
|
||||||
|
- alert order is preserved;
|
||||||
|
- run metadata is preserved when all alerts are filtered out;
|
||||||
|
- filtering does not mutate the repository-owned run, alerts, references, or timestamp pointers.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stage 2: HTTP Adapter Wiring
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- Extend `internal/adapters/inbound/httpapi.Service` with the app service method selected in Stage 1.
|
||||||
|
- Add adapter-local clock injection near `alerts_endpoint.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var alertNow = time.Now
|
||||||
|
```
|
||||||
|
|
||||||
|
- Update `/alerts/active` handler to call the new active-alert service method with `alertNow().UTC()`.
|
||||||
|
- Keep `bindQuery` as the binder so public query support remains `format` and `units` only.
|
||||||
|
- Keep the text template name `alerts_active.txt.tmpl`.
|
||||||
|
- Do not add timezone support in this cleanup. Alert timestamp presentation currently returns canonical model timestamps; timezone support would be a separate public API expansion.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Update HTTP tests covering:
|
||||||
|
|
||||||
|
- route registration remains unchanged;
|
||||||
|
- handler passes `alertNow().UTC()` to the service;
|
||||||
|
- JSON success response renders filtered service output;
|
||||||
|
- text output renders zero active alerts as `Alerts: 0` when data is non-null;
|
||||||
|
- `data: null` still renders no-data behavior;
|
||||||
|
- `units=us` remains accepted and has no schema effect;
|
||||||
|
- `precision`, `tz`, `TZ`, and unknown query params still return `400`.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/adapters/inbound/httpapi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stage 3: Presenter And Template Check
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- Keep `presenter.AlertsPayload` as a pass-through unless filtering requires defensive copy behavior at presentation time.
|
||||||
|
- Do not move active filtering into the presenter.
|
||||||
|
- Review `templates/alerts_active.txt.tmpl` and ensure it behaves correctly when `Data` is non-null and `Alerts` is empty.
|
||||||
|
- If needed, add an explicit text fixture/assertion rather than changing template wording broadly.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Add or update presenter/template-sensitive tests only if current endpoint tests do not prove:
|
||||||
|
|
||||||
|
- nil data renders no-data text;
|
||||||
|
- non-null data with empty alerts renders an active-alert run with `Alerts: 0`;
|
||||||
|
- expired/canceled alerts do not appear in text output after service filtering.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/adapters/inbound/httpapi ./internal/adapters/inbound/httpapi/presenter
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stage 4: Documentation Updates
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
Update current-behavior docs after implementation is complete:
|
||||||
|
|
||||||
|
- `docs/api.md`:
|
||||||
|
- clarify that `/alerts/active` returns the latest alert run filtered to alerts active at request time;
|
||||||
|
- document that no current active alerts returns a run with `alerts: []` when a latest run exists;
|
||||||
|
- keep `data: null` limited to no stored alert run;
|
||||||
|
- keep supported query params as `format` and `units`.
|
||||||
|
- `README.md` if endpoint summaries mention active alerts.
|
||||||
|
- `docs/internal/postgres-repository.md`:
|
||||||
|
- clarify that `LatestAlertRun` reads the latest stored snapshot and active filtering is performed in the app service.
|
||||||
|
- `docs/policy/architecture.md` only if its latest-resource wording needs to distinguish latest snapshots from request-time derived active views.
|
||||||
|
|
||||||
|
Do not document this unimplemented behavior outside `docs/roadmap/` before the code change lands.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
No doc-specific tests are required unless existing docs consistency tests cover endpoint summaries.
|
||||||
|
|
||||||
|
## Stage 5: Final Verification
|
||||||
|
|
||||||
|
Run focused tests:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/app ./internal/adapters/inbound/httpapi ./internal/adapters/inbound/httpapi/presenter
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the full suite:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual smoke checks after deployment:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl 'https://weather.api.rakestrawhome.com/alerts/active?format=json'
|
||||||
|
curl 'https://weather.api.rakestrawhome.com/alerts/active?format=text'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected behavior when all alerts in the latest stored run are expired:
|
||||||
|
|
||||||
|
- JSON returns a non-null `data` object with `alerts: []`.
|
||||||
|
- Text output shows the run metadata and `Alerts: 0`.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Do not change weatherfeeder ingestion behavior in this cleanup.
|
||||||
|
- Do not alter Postgres tables or SQL filtering unless a later performance issue justifies it.
|
||||||
|
- Do not make `weatherapi` poll NWS or any upstream provider.
|
||||||
|
- Do not rename `/alerts/active`.
|
||||||
|
- Do not add alert history endpoints in this cleanup.
|
||||||
|
- Do not introduce generic filtering frameworks; use small app-local helpers.
|
||||||
|
- Do not move route query validation into the app layer.
|
||||||
|
- Do not move active filtering into presenters or text templates.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- `weatherfeeder` persists alert snapshots that were active at ingestion time, but `weatherapi` must still enforce request-time active semantics.
|
||||||
|
- Cancellation messages are operational metadata, not active hazards, and should not be returned by `/alerts/active`.
|
||||||
|
- Missing `expires` should not suppress an alert unless future real-world data shows this creates stale records.
|
||||||
|
- Time comparisons should use UTC instants; timezone presentation is not part of this cleanup.
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
# SPC Convective Outlook API Roadmap
|
|
||||||
|
|
||||||
There are no active roadmap items for the SPC convective outlook API.
|
|
||||||
|
|
||||||
Current behavior is documented in:
|
|
||||||
|
|
||||||
- [`docs/api.md`](../api.md)
|
|
||||||
- [`docs/internal/http-adapter.md`](../internal/http-adapter.md)
|
|
||||||
- [`docs/internal/presenters.md`](../internal/presenters.md)
|
|
||||||
- [`docs/internal/postgres-repository.md`](../internal/postgres-repository.md)
|
|
||||||
- [`docs/integrations/weatherfeeder-postgres.md`](../integrations/weatherfeeder-postgres.md)
|
|
||||||
|
|
||||||
Future changes to convective outlook behavior should be proposed in a new
|
|
||||||
roadmap entry before implementation.
|
|
||||||
@@ -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