Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6316783c3a | |||
| cdfe8881d9 | |||
| 8d0bc90f9d | |||
| 12a7447494 | |||
| dd2f24316c | |||
| 90abc536dd | |||
| 370d7c0928 | |||
| b556052966 | |||
| c7760742e6 | |||
| beeaeeaecb | |||
| 1f2459923f | |||
| fa1b2cb390 | |||
| 82dd304f10 | |||
| 14c6954296 | |||
| 0135eb1153 |
77
docs/api.md
77
docs/api.md
@@ -194,10 +194,14 @@ GET /conditions/current?format=json&precision=0
|
||||
GET /alerts/active
|
||||
```
|
||||
|
||||
Returns the latest active-alert snapshot.
|
||||
Returns the latest stored alert run filtered to alerts active at request time.
|
||||
|
||||
Query parameters: `format`, `units`.
|
||||
|
||||
When no latest alert run exists, `data` is null. When a latest run exists but
|
||||
no alerts are currently active, `data` remains an object and `alerts` is an
|
||||
empty array.
|
||||
|
||||
Run `data` fields:
|
||||
|
||||
| Field | Type | Notes |
|
||||
@@ -207,6 +211,10 @@ Run `data` fields:
|
||||
| `latitude`, `longitude` | number | optional |
|
||||
| `alerts` | array | active alerts, possibly empty |
|
||||
|
||||
Alerts are active when `messageType` is not `Cancel`, `effective` is absent or
|
||||
at or before request time, and `expires` is absent or after request time.
|
||||
`onset` is presented when available but is not used as the active boundary.
|
||||
|
||||
Alert fields include `id`, `event`, `headline`, `severity`, `urgency`,
|
||||
`certainty`, `status`, `messageType`, `category`, `response`, `description`,
|
||||
`instruction`, `sent`, `effective`, `onset`, `expires`, `areaDescription`,
|
||||
@@ -230,19 +238,22 @@ GET /outlooks/convective/location
|
||||
```
|
||||
|
||||
Returns the latest SPC convective outlook run reconstructed from
|
||||
weatherfeeder-owned Postgres tables.
|
||||
weatherfeeder-owned `weather.outlook.v2` Postgres tables.
|
||||
|
||||
Route behavior:
|
||||
|
||||
- `/outlooks/convective` returns the latest run with all stored outlook
|
||||
polygons unless user filters are supplied.
|
||||
- `/outlooks/convective` returns the latest run with stored location-filtered
|
||||
outlook polygons unless user filters are supplied.
|
||||
- `/outlooks/convective/active` adds an active-time filter using the server's
|
||||
current UTC time. Outlooks are active when `validFrom <= now < validTo`.
|
||||
- `/outlooks/convective/location` adds the same active-time filter and
|
||||
`containsLocation=true`.
|
||||
`containsLocation=true`. It remains as an active local-outlook compatibility
|
||||
route under the v2 weatherfeeder contract.
|
||||
|
||||
When no latest run exists, `data` is null. When a run exists but filters match
|
||||
no outlooks, `data` remains an object and `outlooks` is an empty array.
|
||||
no outlooks, `data` remains an object and `outlooks` and `discussions` are
|
||||
empty arrays. Outlook endpoints use latest-run semantics and do not accumulate
|
||||
historical active outlooks across older runs.
|
||||
|
||||
Query parameters:
|
||||
|
||||
@@ -253,9 +264,12 @@ Query parameters:
|
||||
| `outlookType` | all outlook routes | `categorical`, `tornado`, `hail`, or `wind` |
|
||||
| `containsLocation` | `/outlooks/convective`, `/outlooks/convective/active` | boolean |
|
||||
|
||||
`outlookType` values are normalized case-insensitively. `containsLocation` is
|
||||
rejected on `/outlooks/convective/location` because that route always applies
|
||||
`containsLocation=true`. `precision` and unknown parameters are rejected.
|
||||
`outlookType` values are normalized case-insensitively. Weatherfeeder v2
|
||||
outlooks are already filtered for the configured location, so
|
||||
`containsLocation` is expected to be true for stored v2 outlooks.
|
||||
`containsLocation` is rejected on `/outlooks/convective/location` because that
|
||||
route always applies `containsLocation=true`. `precision` and unknown
|
||||
parameters are rejected.
|
||||
|
||||
Run `data` fields:
|
||||
|
||||
@@ -266,6 +280,7 @@ Run `data` fields:
|
||||
| `asOf` | RFC3339 datetime | required when `data` is not null |
|
||||
| `issuedAt` | RFC3339 datetime | optional |
|
||||
| `outlooks` | array | ordered outlook polygons, possibly empty |
|
||||
| `discussions` | array | ordered day-level discussions, possibly empty |
|
||||
|
||||
Outlook fields:
|
||||
|
||||
@@ -273,17 +288,27 @@ Outlook fields:
|
||||
| --- | --- | --- |
|
||||
| `id`, `provider`, `product`, `outlookType`, `label` | string | required when an outlook is present |
|
||||
| `day` | integer | SPC outlook day |
|
||||
| `labelText`, `forecaster`, `headline`, `summary`, `discussion` | string | optional |
|
||||
| `labelText`, `forecaster` | string | optional |
|
||||
| `severityRank` | integer | optional |
|
||||
| `validFrom`, `validTo`, `issuedAt`, `expiresAt` | RFC3339 datetime | required when an outlook is present |
|
||||
| `sourceUrl`, `imageUrl` | string | optional |
|
||||
| `containsLocation` | boolean | whether the outlook polygon contains the configured location |
|
||||
| `geometry` | GeoJSON | stored outlook geometry |
|
||||
|
||||
Discussion fields:
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `day` | integer | SPC outlook day |
|
||||
| `headline`, `summary`, `discussion` | string | optional |
|
||||
| `updatedAt` | RFC3339 datetime | optional |
|
||||
|
||||
GeoJSON coordinates use standard GeoJSON coordinate order: longitude, then
|
||||
latitude. Timezone conversion applies to run `asOf`, run `issuedAt`, and each
|
||||
outlook's `validFrom`, `validTo`, `issuedAt`, and `expiresAt`. Active filtering
|
||||
compares instants and is not changed by the presentation timezone.
|
||||
outlook's `validFrom`, `validTo`, `issuedAt`, and `expiresAt`, and discussion
|
||||
`updatedAt`. Active filtering compares instants and is not changed by the
|
||||
presentation timezone. Endpoint filters also filter `discussions` to days
|
||||
represented by retained outlooks.
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -328,6 +353,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 +388,25 @@ Example location-filtered JSON response:
|
||||
"expiresAt": "2026-06-12T12:00:00Z",
|
||||
"containsLocation": true,
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [-90.2, 38.6]
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[-90.6, 38.4],
|
||||
[-90.0, 38.4],
|
||||
[-90.0, 38.8],
|
||||
[-90.6, 38.4]
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"discussions": [
|
||||
{
|
||||
"day": 1,
|
||||
"headline": "Tornado risk near the configured location",
|
||||
"discussion": "SPC tornado outlook discussion text.",
|
||||
"updatedAt": "2026-06-11T17:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
|
||||
`go.mod` depends on:
|
||||
|
||||
- `gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0`
|
||||
- `gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0`
|
||||
|
||||
The repository code also depends on weatherfeeder canonical model types. Table
|
||||
compatibility must match the SQL in `internal/adapters/outbound/postgres`.
|
||||
For convective outlooks, `weatherapi` assumes weatherfeeder's
|
||||
`weather.outlook.v2` table reset has already been applied.
|
||||
|
||||
## Boundary
|
||||
|
||||
@@ -37,7 +39,7 @@ Postgres owns persistence, backup, restore, and availability.
|
||||
| Forecast discussion | `forecast_discussions`, `forecast_discussion_key_messages` |
|
||||
| Weather story run | `weather_story_runs`, `weather_stories` |
|
||||
| Latest weather story | `weather_stories` |
|
||||
| Convective outlook run | `outlook_runs`, `outlooks` |
|
||||
| Convective outlook run | `outlook_runs`, `outlooks`, `outlook_discussions` |
|
||||
|
||||
## Latest Row Selection
|
||||
|
||||
@@ -68,7 +70,8 @@ Child rows are loaded separately and attached in stored order:
|
||||
- forecast periods: `period_index ASC`;
|
||||
- forecast discussion key messages: `message_index ASC`;
|
||||
- weather stories for a run: `story_index ASC`;
|
||||
- outlooks for a run: `outlook_index ASC`.
|
||||
- outlooks for a run: `outlook_index ASC`;
|
||||
- outlook discussions for a run: `discussion_index ASC`.
|
||||
|
||||
## Columns Read
|
||||
|
||||
@@ -160,12 +163,17 @@ routes.
|
||||
|
||||
`outlook_index`, `outlook_id`, `provider`, `product`, `day`, `outlook_type`,
|
||||
`label`, `label_text`, `severity_rank`, `valid_from`, `valid_to`, `issued_at`,
|
||||
`expires_at`, `forecaster`, `headline`, `summary`, `discussion`, `source_url`,
|
||||
`image_url`, `contains_location`, `geometry_json`, and `run_event_id`.
|
||||
`expires_at`, `forecaster`, `source_url`, `image_url`, `contains_location`,
|
||||
`geometry_json`, and `run_event_id`.
|
||||
|
||||
`geometry_json` is copied into response GeoJSON without parsing or
|
||||
reserializing. It must contain valid JSON.
|
||||
|
||||
### `outlook_discussions`
|
||||
|
||||
`discussion_index`, `day`, `headline`, `summary`, `discussion`, `updated_at`,
|
||||
and `run_event_id`.
|
||||
|
||||
## Nullability and Time Assumptions
|
||||
|
||||
The repository scans nullable columns with `sql.Null*` types and maps them to
|
||||
|
||||
@@ -9,7 +9,7 @@ This document describes the internal HTTP adapter under
|
||||
The HTTP adapter turns feedapi route definitions into calls on the application
|
||||
service boundary. It owns route registration, query binding, request validation,
|
||||
forecast day-slice filtering, outlook active/location filter construction,
|
||||
response envelopes, and template names.
|
||||
alert active-time selection, response envelopes, and template names.
|
||||
|
||||
## Inputs and Outputs
|
||||
|
||||
@@ -35,6 +35,7 @@ The adapter may:
|
||||
- call the `Service` interface;
|
||||
- choose the presenter function for an endpoint;
|
||||
- filter forecast copies for `/today` and `/tomorrow`;
|
||||
- pass the current UTC instant to active-alert application filtering;
|
||||
- construct outlook active/location filters.
|
||||
|
||||
The adapter must not:
|
||||
@@ -61,9 +62,9 @@ declared in endpoint definitions, but `templates.base_dir` is loaded by feedapi.
|
||||
|
||||
## State
|
||||
|
||||
The adapter has no durable state. `forecastNow` and `outlookNow` are
|
||||
package-level state only to make time-dependent endpoint tests deterministic.
|
||||
Do not add request caches or cross-request mutable state here.
|
||||
The adapter has no durable state. `forecastNow`, `alertNow`, and `outlookNow`
|
||||
are package-level state only to make time-dependent endpoint tests
|
||||
deterministic. Do not add request caches or cross-request mutable state here.
|
||||
|
||||
## Route Registry
|
||||
|
||||
@@ -131,6 +132,12 @@ Filtering behavior:
|
||||
The package variable `forecastNow` exists so endpoint tests can make day-slice
|
||||
behavior deterministic.
|
||||
|
||||
## Alert Active Time
|
||||
|
||||
`/alerts/active` uses the shared `format` and `units` binder. The handler calls
|
||||
the application service with `alertNow().UTC()` so active alert filtering uses
|
||||
the request-time instant while remaining deterministic in endpoint tests.
|
||||
|
||||
## Outlook Filters
|
||||
|
||||
Outlook route filters are built at the HTTP boundary and passed to the
|
||||
@@ -144,6 +151,11 @@ application service:
|
||||
The package variable `outlookNow` exists so endpoint tests can make active and
|
||||
location filtering deterministic.
|
||||
|
||||
The application service returns filtered outlook copies and trims run-level
|
||||
discussions to days represented by retained outlooks.
|
||||
`/outlooks/convective/location` is retained for compatibility and active
|
||||
local-outlook behavior under the weatherfeeder outlook v2 contract.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
Binder failures become feedapi invalid-parameter responses. Handler service
|
||||
|
||||
@@ -79,7 +79,8 @@ successful responses with `data: null`.
|
||||
- `CurrentConditions`: aggregates recent rows from `observations` using the
|
||||
application-provided observation window.
|
||||
- `LatestAlertRun`: latest row from `alert_runs`, then child `alerts` and
|
||||
`alert_references`.
|
||||
`alert_references`. This is the latest stored alert snapshot; active-time
|
||||
filtering is performed by the application service.
|
||||
- `LatestHourlyForecast`: latest `forecasts` row where `product = 'hourly'`,
|
||||
then child `forecast_periods`.
|
||||
- `LatestNarrativeForecast`: latest `forecasts` row where
|
||||
@@ -90,7 +91,7 @@ successful responses with `data: null`.
|
||||
`weather_stories`.
|
||||
- `LatestWeatherStory`: latest individual row from `weather_stories`.
|
||||
- `LatestConvectiveOutlookRun`: latest row from `outlook_runs`, then child
|
||||
`outlooks`.
|
||||
`outlooks` and `outlook_discussions`.
|
||||
|
||||
Latest parent rows are selected by descending weather timestamp and
|
||||
`event_emitted_at` where that tie-breaker is available in the query.
|
||||
@@ -105,7 +106,8 @@ Child queries preserve stored order:
|
||||
- forecast periods by `period_index`;
|
||||
- discussion key messages by `message_index`;
|
||||
- weather stories by `story_index`;
|
||||
- outlooks by `outlook_index`.
|
||||
- outlooks by `outlook_index`;
|
||||
- outlook discussions by `discussion_index`.
|
||||
|
||||
Alert references are attached after both alert and reference rows are loaded.
|
||||
References are grouped by alert index and attached to their corresponding alert.
|
||||
|
||||
@@ -118,9 +118,11 @@ response envelope so renderers can produce `data: null`.
|
||||
- Discussions: full or focused payload shapes, section copy, key-message copy,
|
||||
timezone conversion.
|
||||
- Weather stories: run/story copy and timezone conversion.
|
||||
- Convective outlooks: canonical model copy, pointer and geometry copy, and
|
||||
timezone conversion. `units` is accepted by routes but ignored by the
|
||||
presenter because outlook fields are not unit-bearing.
|
||||
- Convective outlooks: canonical model copy, pointer and geometry copy,
|
||||
run-level discussion copy, and timezone conversion for run, outlook, and
|
||||
discussion timestamps. Outlook polygon prose is not handled by the presenter;
|
||||
prose is carried by run-level discussions. `units` is accepted by routes but
|
||||
ignored by the presenter because outlook fields are not unit-bearing.
|
||||
|
||||
## Templates
|
||||
|
||||
|
||||
@@ -91,8 +91,13 @@ build.
|
||||
## Database Dependency
|
||||
|
||||
`weatherapi` expects weatherfeeder-compatible tables for observations, current
|
||||
conditions aggregation, active alerts, forecasts, forecast discussions, and
|
||||
weather stories. It only reads those tables.
|
||||
conditions aggregation, active alerts, forecasts, forecast discussions, weather
|
||||
stories, and convective outlooks. It only reads those tables.
|
||||
|
||||
Convective outlook endpoints require weatherfeeder's `weather.outlook.v2` table
|
||||
shape, including `outlook_runs`, `outlooks`, and `outlook_discussions`. If
|
||||
operators reset or recreate outlook tables during a weatherfeeder upgrade,
|
||||
complete that weatherfeeder-side migration before starting `weatherapi`.
|
||||
|
||||
Operational ownership is split:
|
||||
|
||||
@@ -113,7 +118,7 @@ template named by each endpoint, with templates stored under `templates.base_dir
|
||||
|
||||
The repository includes templates for all implemented endpoint families:
|
||||
observations, current conditions, active alerts, hourly forecasts, narrative
|
||||
forecasts, forecast discussions, and weather stories.
|
||||
forecasts, forecast discussions, weather stories, and convective outlooks.
|
||||
|
||||
If text rendering fails or returns an unsupported-format error, verify:
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ Outputs are HTTP responses in JSON, XML, or text format. All public endpoint han
|
||||
|
||||
`weatherapi` owns no durable weather state. Its runtime state is limited to process memory, loaded configuration, HTTP server state, template registry, renderer registry, and database connection pools. Durable weather data and schema creation are external concerns owned by `weatherfeeder` and Postgres.
|
||||
|
||||
The API currently serves latest-resource views: latest observation, current conditions, latest active alerts run, latest hourly forecast, latest narrative forecast, latest forecast discussion, latest weather story run, latest individual weather story, and latest convective outlook run. Forecast `today` and `tomorrow` routes derive filtered copies from the latest run. Convective outlook active and location routes derive filtered copies from the latest run.
|
||||
The API currently serves latest-resource views: latest observation, current conditions, latest hourly forecast, latest narrative forecast, latest forecast discussion, latest weather story run, latest individual weather story, and latest convective outlook run. Active alert, forecast `today` and `tomorrow`, and convective outlook active/location routes derive filtered copies from the latest stored run.
|
||||
|
||||
## Configuration and CLI Boundaries
|
||||
|
||||
|
||||
@@ -1,14 +1,247 @@
|
||||
# SPC Convective Outlook API Roadmap
|
||||
# Active Alert Filtering Cleanup
|
||||
|
||||
There are no active roadmap items for the SPC convective outlook API.
|
||||
## Status
|
||||
|
||||
Current behavior is documented in:
|
||||
Implementation, current-behavior documentation, and automated verification are
|
||||
complete.
|
||||
|
||||
- [`docs/api.md`](../api.md)
|
||||
- [`docs/internal/http-adapter.md`](../internal/http-adapter.md)
|
||||
- [`docs/internal/presenters.md`](../internal/presenters.md)
|
||||
- [`docs/internal/postgres-repository.md`](../internal/postgres-repository.md)
|
||||
- [`docs/integrations/weatherfeeder-postgres.md`](../integrations/weatherfeeder-postgres.md)
|
||||
## Verification Record
|
||||
|
||||
Future changes to convective outlook behavior should be proposed in a new
|
||||
roadmap entry before implementation.
|
||||
Completed on 2026-06-12:
|
||||
|
||||
- `go test ./internal/app`
|
||||
- `go test ./internal/adapters/inbound/httpapi`
|
||||
- `go test ./internal/adapters/inbound/httpapi/presenter`
|
||||
- `go test ./...`
|
||||
|
||||
Deployment smoke checks were not run from this workspace because the local
|
||||
repository state is not a deployed `weatherapi` instance.
|
||||
|
||||
## Summary
|
||||
|
||||
Fix `/alerts/active` so it returns alerts that are active at request time, not merely every alert from the latest stored alert snapshot.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## 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;
|
||||
- `precision` outside `0` through `2`, or not an integer;
|
||||
- `precision` used on alerts, discussions, or weather stories;
|
||||
- `precision` used on alerts, discussions, weather stories, or outlooks;
|
||||
- `tz` used on observations, current conditions, or alerts;
|
||||
- invalid timezone value;
|
||||
- both `tz` and `TZ` are present with different values.
|
||||
@@ -187,6 +187,10 @@ Safe fix: verify weatherfeeder is running and writing to the same database that
|
||||
`weatherapi` uses as its first configured database. For current conditions,
|
||||
wait for recent observations or inspect weatherfeeder ingestion.
|
||||
|
||||
For convective outlooks, also verify that weatherfeeder has applied its
|
||||
`weather.outlook.v2` table shape and is writing `outlook_runs`, `outlooks`, and
|
||||
`outlook_discussions`.
|
||||
|
||||
## Forecast Day Routes Return Empty `periods`
|
||||
|
||||
Symptom: `/forecast/hourly/today`, `/forecast/hourly/tomorrow`,
|
||||
|
||||
2
go.mod
2
go.mod
@@ -4,7 +4,7 @@ go 1.25.5
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/ejr/feedapi v0.1.0
|
||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0
|
||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0
|
||||
github.com/lib/pq v1.10.9
|
||||
)
|
||||
|
||||
|
||||
4
go.sum
4
go.sum
@@ -1,7 +1,7 @@
|
||||
gitea.maximumdirect.net/ejr/feedapi v0.1.0 h1:ZB5QWKD5DPFV3P7vyeJqXPMcSWN9qHkDUHw1LgN9hwY=
|
||||
gitea.maximumdirect.net/ejr/feedapi v0.1.0/go.mod h1:3fIaFFx4ywt0TWbN8DIIBAHJn7ZQUm6PNcceqRgy3bw=
|
||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0 h1:4hRiktLMFS8Arl98j21YYskXT8RHHQGFDPJxOF3CpwU=
|
||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.11.0/go.mod h1:VVtuwrbddWdUu21ovCSSojhH5J9P6kk0/dfnFqC4/Lw=
|
||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0 h1:U3yln3o2rGqfMvWVRwOGgQeqYuqMm+/p0XIRhK8TDUQ=
|
||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0/go.mod h1:VVtuwrbddWdUu21ovCSSojhH5J9P6kk0/dfnFqC4/Lw=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
|
||||
@@ -4,6 +4,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/feedapi/endpoint"
|
||||
"gitea.maximumdirect.net/ejr/feedapi/render"
|
||||
@@ -11,12 +12,14 @@ import (
|
||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
|
||||
)
|
||||
|
||||
var alertNow = time.Now
|
||||
|
||||
func alertsDefinition(svc Service) endpoint.Definition {
|
||||
return endpoint.GET(
|
||||
"/alerts/active",
|
||||
bindQuery,
|
||||
func(ctx context.Context, req queryRequest) (any, error) {
|
||||
run, err := svc.LatestAlertRun(ctx)
|
||||
run, err := svc.LatestActiveAlertRun(ctx, alertNow().UTC())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ type fakeService struct {
|
||||
weatherStoryRun *model.WeatherStoryRun
|
||||
weatherStory *model.WeatherStory
|
||||
alerts *model.WeatherAlertRun
|
||||
activeAlertAt []time.Time
|
||||
outlookRun *model.WeatherOutlookRun
|
||||
outlookFilters []app.OutlookFilter
|
||||
conditions *app.CurrentConditions
|
||||
@@ -64,6 +65,11 @@ func (s *fakeService) LatestAlertRun(context.Context) (*model.WeatherAlertRun, e
|
||||
return s.alerts, s.err
|
||||
}
|
||||
|
||||
func (s *fakeService) LatestActiveAlertRun(_ context.Context, activeAt time.Time) (*model.WeatherAlertRun, error) {
|
||||
s.activeAlertAt = append(s.activeAlertAt, activeAt)
|
||||
return s.alerts, s.err
|
||||
}
|
||||
|
||||
func (s *fakeService) LatestConvectiveOutlook(_ context.Context, filter app.OutlookFilter) (*model.WeatherOutlookRun, error) {
|
||||
s.outlookFilters = append(s.outlookFilters, filter)
|
||||
return s.outlookRun, s.err
|
||||
@@ -73,6 +79,46 @@ func (s *fakeService) CurrentConditions(context.Context) (*app.CurrentConditions
|
||||
return s.conditions, s.err
|
||||
}
|
||||
|
||||
type alertRepository struct {
|
||||
alerts *model.WeatherAlertRun
|
||||
}
|
||||
|
||||
func (r *alertRepository) LatestObservation(context.Context) (*model.WeatherObservation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *alertRepository) LatestHourlyForecast(context.Context) (*model.WeatherForecastRun, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *alertRepository) LatestNarrativeForecast(context.Context) (*model.WeatherForecastRun, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *alertRepository) LatestForecastDiscussion(context.Context) (*model.WeatherForecastDiscussion, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *alertRepository) LatestWeatherStoryRun(context.Context) (*model.WeatherStoryRun, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *alertRepository) LatestWeatherStory(context.Context) (*model.WeatherStory, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *alertRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
|
||||
return r.alerts, nil
|
||||
}
|
||||
|
||||
func (r *alertRepository) LatestConvectiveOutlookRun(context.Context) (*model.WeatherOutlookRun, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *alertRepository) CurrentConditions(context.Context, int) (*app.CurrentConditions, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestObservationsRejectUnknownQueryParameter(t *testing.T) {
|
||||
h := newHandler(t, &fakeService{}, "/observations")
|
||||
|
||||
@@ -315,6 +361,153 @@ func TestAlertsUSUnitsKeepSchema(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertsRouteRegistered(t *testing.T) {
|
||||
def := definitionForPath(t, Definitions(&fakeService{}), "/alerts/active")
|
||||
if len(def.Methods) != 1 || def.Methods[0] != http.MethodGet {
|
||||
t.Fatalf("expected GET definition, got %+v", def.Methods)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertsHandlerPassesCurrentUTCInstant(t *testing.T) {
|
||||
now := time.Date(2026, 6, 11, 7, 30, 0, 0, time.FixedZone("LOCAL", -5*60*60))
|
||||
setAlertNowForTest(t, now)
|
||||
svc := &fakeService{
|
||||
alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()},
|
||||
}
|
||||
h := newHandler(t, svc, "/alerts/active")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/alerts/active", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if len(svc.activeAlertAt) != 1 {
|
||||
t.Fatalf("expected one active-alert service call, got %d", len(svc.activeAlertAt))
|
||||
}
|
||||
want := now.UTC()
|
||||
if !svc.activeAlertAt[0].Equal(want) {
|
||||
t.Fatalf("expected activeAt %v, got %v", want, svc.activeAlertAt[0])
|
||||
}
|
||||
if svc.activeAlertAt[0].Location() != time.UTC {
|
||||
t.Fatalf("expected UTC location, got %v", svc.activeAlertAt[0].Location())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertsNoDataReturnsNullEnvelopeData(t *testing.T) {
|
||||
h := newHandler(t, &fakeService{}, "/alerts/active")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/alerts/active", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Data *json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode envelope: %v", err)
|
||||
}
|
||||
if payload.Data != nil {
|
||||
t.Fatalf("expected data null, got %s", string(*payload.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertsTextRendersNoData(t *testing.T) {
|
||||
h := newHandler(t, &fakeService{}, "/alerts/active")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/alerts/active?format=text", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "No active alerts data available.") {
|
||||
t.Fatalf("expected no-data alert text, got %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertsTextRendersEmptyActiveSet(t *testing.T) {
|
||||
h := newHandler(t, &fakeService{
|
||||
alerts: &model.WeatherAlertRun{
|
||||
AsOf: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
|
||||
Alerts: []model.WeatherAlert{},
|
||||
},
|
||||
}, "/alerts/active")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/alerts/active?format=text", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "Alerts: 0") {
|
||||
t.Fatalf("expected empty alert count in text body, got %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertsTextOmitsInactiveAlertsAfterServiceFiltering(t *testing.T) {
|
||||
activeAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
||||
setAlertNowForTest(t, activeAt)
|
||||
effective := activeAt.Add(-1 * time.Hour)
|
||||
activeExpires := activeAt.Add(1 * time.Hour)
|
||||
expiredAtBoundary := activeAt
|
||||
repo := &alertRepository{
|
||||
alerts: &model.WeatherAlertRun{
|
||||
AsOf: activeAt,
|
||||
Alerts: []model.WeatherAlert{
|
||||
{
|
||||
ID: "active-alert",
|
||||
Headline: "Active warning",
|
||||
MessageType: "Alert",
|
||||
Effective: &effective,
|
||||
Expires: &activeExpires,
|
||||
},
|
||||
{
|
||||
ID: "expired-alert",
|
||||
Headline: "Expired warning",
|
||||
MessageType: "Alert",
|
||||
Effective: &effective,
|
||||
Expires: &expiredAtBoundary,
|
||||
},
|
||||
{
|
||||
ID: "canceled-alert",
|
||||
Headline: "Canceled warning",
|
||||
MessageType: " cancel ",
|
||||
Effective: &effective,
|
||||
Expires: &activeExpires,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
h := newHandler(t, app.NewService(repo), "/alerts/active")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/alerts/active?format=text", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"Alerts: 1", "active-alert", "Active warning"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected %q in text body, got %q", want, body)
|
||||
}
|
||||
}
|
||||
for _, notWant := range []string{"expired-alert", "Expired warning", "canceled-alert", "Canceled warning"} {
|
||||
if strings.Contains(body, notWant) {
|
||||
t.Fatalf("did not expect %q in text body, got %q", notWant, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestObservationUSUnitsWithXMLFormat(t *testing.T) {
|
||||
h := newHandler(t, &fakeService{
|
||||
observation: &model.WeatherObservation{
|
||||
@@ -1265,17 +1458,24 @@ func TestPrecisionValidationRange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertsRejectPrecisionQueryParameter(t *testing.T) {
|
||||
func TestAlertsRejectUnsupportedQueryParameters(t *testing.T) {
|
||||
h := newHandler(t, &fakeService{
|
||||
alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()},
|
||||
}, "/alerts/active")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/alerts/active?precision=1", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
for _, rawURL := range []string{
|
||||
"/alerts/active?precision=1",
|
||||
"/alerts/active?tz=CDT",
|
||||
"/alerts/active?TZ=CDT",
|
||||
"/alerts/active?bogus=1",
|
||||
} {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, rawURL, nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s: expected 400, got %d", rawURL, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1314,10 +1514,9 @@ func TestOutlookRoutesJSONSuccess(t *testing.T) {
|
||||
|
||||
var payload struct {
|
||||
Data struct {
|
||||
LocationID string `json:"locationId"`
|
||||
Outlooks []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"outlooks"`
|
||||
LocationID string `json:"locationId"`
|
||||
Outlooks []map[string]any `json:"outlooks"`
|
||||
Discussions []outlookDiscussionCheck `json:"discussions"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
@@ -1326,9 +1525,20 @@ func TestOutlookRoutesJSONSuccess(t *testing.T) {
|
||||
if payload.Data.LocationID != "stl" {
|
||||
t.Fatalf("expected locationId stl, got %q", payload.Data.LocationID)
|
||||
}
|
||||
if len(payload.Data.Outlooks) != 1 || payload.Data.Outlooks[0].ID != "cat-1" {
|
||||
if len(payload.Data.Outlooks) != 1 || payload.Data.Outlooks[0]["id"] != "cat-1" {
|
||||
t.Fatalf("unexpected outlooks payload: %+v", payload.Data.Outlooks)
|
||||
}
|
||||
for _, field := range []string{"headline", "summary", "discussion"} {
|
||||
if _, ok := payload.Data.Outlooks[0][field]; ok {
|
||||
t.Fatalf("expected outlook polygon to omit %s, got %+v", field, payload.Data.Outlooks[0])
|
||||
}
|
||||
}
|
||||
if len(payload.Data.Discussions) != 1 {
|
||||
t.Fatalf("expected one discussion, got %+v", payload.Data.Discussions)
|
||||
}
|
||||
if payload.Data.Discussions[0].Day != 1 || payload.Data.Discussions[0].Headline != "Day 1 headline" {
|
||||
t.Fatalf("unexpected discussions payload: %+v", payload.Data.Discussions)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1369,7 +1579,8 @@ func TestOutlookTextResponseUsesTemplate(t *testing.T) {
|
||||
t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "Convective Outlook") || !strings.Contains(body, "Outlooks: 1") {
|
||||
if !strings.Contains(body, "Convective Outlook") || !strings.Contains(body, "Outlooks: 1") ||
|
||||
!strings.Contains(body, "Discussions: 1") || !strings.Contains(body, "Day 1 discussion") {
|
||||
t.Fatalf("expected outlook text template body, got %q", body)
|
||||
}
|
||||
}
|
||||
@@ -1390,11 +1601,15 @@ func TestOutlookXMLResponseRenders(t *testing.T) {
|
||||
if !strings.Contains(w.Body.String(), "<LocationID>stl</LocationID>") {
|
||||
t.Fatalf("expected outlook XML payload, got %q", w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "<Discussions>") || !strings.Contains(w.Body.String(), "<Headline>Day 1 headline</Headline>") {
|
||||
t.Fatalf("expected outlook XML discussions, got %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
|
||||
run := testOutlookRun()
|
||||
run.Outlooks = []model.WeatherOutlook{}
|
||||
run.Discussions = []model.WeatherOutlookDiscussion{}
|
||||
h := newHandler(t, &fakeService{outlookRun: run}, "/outlooks/convective")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -1407,8 +1622,9 @@ func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
|
||||
|
||||
var payload struct {
|
||||
Data struct {
|
||||
LocationID string `json:"locationId"`
|
||||
Outlooks []model.WeatherOutlook `json:"outlooks"`
|
||||
LocationID string `json:"locationId"`
|
||||
Outlooks []model.WeatherOutlook `json:"outlooks"`
|
||||
Discussions []model.WeatherOutlookDiscussion `json:"discussions"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
@@ -1420,6 +1636,39 @@ func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
|
||||
if payload.Data.Outlooks == nil || len(payload.Data.Outlooks) != 0 {
|
||||
t.Fatalf("expected empty outlooks slice, got %+v", payload.Data.Outlooks)
|
||||
}
|
||||
if payload.Data.Discussions == nil || len(payload.Data.Discussions) != 0 {
|
||||
t.Fatalf("expected empty discussions slice, got %+v", payload.Data.Discussions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutlookTimezoneQuery(t *testing.T) {
|
||||
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, "/outlooks/convective")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?tz=CDT", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var payload outlookTimePayload
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode outlook payload: %v", err)
|
||||
}
|
||||
assertOffsetSeconds(t, payload.Data.AsOf, -5*60*60)
|
||||
assertOffsetSeconds(t, *payload.Data.IssuedAt, -5*60*60)
|
||||
if len(payload.Data.Outlooks) != 1 {
|
||||
t.Fatalf("expected one outlook, got %+v", payload.Data.Outlooks)
|
||||
}
|
||||
assertOffsetSeconds(t, payload.Data.Outlooks[0].ValidFrom, -5*60*60)
|
||||
assertOffsetSeconds(t, payload.Data.Outlooks[0].ValidTo, -5*60*60)
|
||||
assertOffsetSeconds(t, payload.Data.Outlooks[0].IssuedAt, -5*60*60)
|
||||
assertOffsetSeconds(t, payload.Data.Outlooks[0].ExpiresAt, -5*60*60)
|
||||
if len(payload.Data.Discussions) != 1 || payload.Data.Discussions[0].UpdatedAt == nil {
|
||||
t.Fatalf("expected one discussion with updatedAt, got %+v", payload.Data.Discussions)
|
||||
}
|
||||
assertOffsetSeconds(t, *payload.Data.Discussions[0].UpdatedAt, -5*60*60)
|
||||
}
|
||||
|
||||
func TestOutlookQueryParamsConstructFilter(t *testing.T) {
|
||||
@@ -2211,10 +2460,10 @@ func testRenderers(t *testing.T) *render.Registry {
|
||||
"discussion_long_term.txt.tmpl": "Forecast Discussion Long Term",
|
||||
"forecast_hourly.txt.tmpl": "Forecast text",
|
||||
"forecast_narrative.txt.tmpl": "Narrative Forecast",
|
||||
"outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}{{else}}No convective outlook data available.{{end}}",
|
||||
"outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}\nDiscussions: {{len .Data.Discussions}}{{range .Data.Discussions}}\nDiscussion: {{.Discussion}}{{end}}{{else}}No convective outlook data available.{{end}}",
|
||||
"weatherstories.txt.tmpl": "Weather Stories",
|
||||
"weatherstories_latest.txt.tmpl": "Latest Weather Story",
|
||||
"alerts_active.txt.tmpl": "Alerts text",
|
||||
"alerts_active.txt.tmpl": "{{if .Data}}Active Alerts\nAlerts: {{len .Data.Alerts}}{{range .Data.Alerts}}\n{{.ID}}{{if .Headline}}\nHeadline: {{.Headline}}{{end}}{{end}}{{else}}No active alerts data available.{{end}}",
|
||||
"conditions_current.txt.tmpl": "Conditions text",
|
||||
} {
|
||||
tmpl, err := template.New(name).Parse(body)
|
||||
@@ -2248,8 +2497,16 @@ func setOutlookNowForTest(t *testing.T, now time.Time) {
|
||||
t.Cleanup(func() { outlookNow = original })
|
||||
}
|
||||
|
||||
func setAlertNowForTest(t *testing.T, now time.Time) {
|
||||
t.Helper()
|
||||
original := alertNow
|
||||
alertNow = func() time.Time { return now }
|
||||
t.Cleanup(func() { alertNow = original })
|
||||
}
|
||||
|
||||
func testOutlookRun() *model.WeatherOutlookRun {
|
||||
issuedAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
||||
discussionUpdatedAt := issuedAt.Add(30 * time.Minute)
|
||||
return &model.WeatherOutlookRun{
|
||||
LocationID: "stl",
|
||||
LocationName: "St. Louis",
|
||||
@@ -2270,6 +2527,13 @@ func testOutlookRun() *model.WeatherOutlookRun {
|
||||
ContainsLocation: true,
|
||||
Geometry: []byte(`{"type":"Point","coordinates":[-90.2,38.6]}`),
|
||||
}},
|
||||
Discussions: []model.WeatherOutlookDiscussion{{
|
||||
Day: 1,
|
||||
Headline: "Day 1 headline",
|
||||
Summary: "Day 1 summary",
|
||||
Discussion: "Day 1 discussion",
|
||||
UpdatedAt: &discussionUpdatedAt,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2343,6 +2607,27 @@ type discussionFocusedTimePayload struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type outlookDiscussionCheck struct {
|
||||
Day int `json:"day"`
|
||||
Headline string `json:"headline"`
|
||||
}
|
||||
|
||||
type outlookTimePayload struct {
|
||||
Data struct {
|
||||
AsOf time.Time `json:"asOf"`
|
||||
IssuedAt *time.Time `json:"issuedAt"`
|
||||
Outlooks []struct {
|
||||
ValidFrom time.Time `json:"validFrom"`
|
||||
ValidTo time.Time `json:"validTo"`
|
||||
IssuedAt time.Time `json:"issuedAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
} `json:"outlooks"`
|
||||
Discussions []struct {
|
||||
UpdatedAt *time.Time `json:"updatedAt"`
|
||||
} `json:"discussions"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func decodeForecastTimePayload(t *testing.T, w *httptest.ResponseRecorder) forecastTimePayload {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -22,10 +22,14 @@ func OutlookRunPayload(run *model.WeatherOutlookRun, _ Units, tz *time.Location)
|
||||
AsOf: inLocationTime(run.AsOf, tz),
|
||||
IssuedAt: inLocationTimePtr(run.IssuedAt, tz),
|
||||
Outlooks: make([]model.WeatherOutlook, 0, len(run.Outlooks)),
|
||||
Discussions: make([]model.WeatherOutlookDiscussion, 0, len(run.Discussions)),
|
||||
}
|
||||
for _, outlook := range run.Outlooks {
|
||||
out.Outlooks = append(out.Outlooks, copyOutlook(outlook, tz))
|
||||
}
|
||||
for _, discussion := range run.Discussions {
|
||||
out.Discussions = append(out.Discussions, copyOutlookDiscussion(discussion, tz))
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
@@ -44,9 +48,6 @@ func copyOutlook(outlook model.WeatherOutlook, tz *time.Location) model.WeatherO
|
||||
IssuedAt: inLocationTime(outlook.IssuedAt, tz),
|
||||
ExpiresAt: inLocationTime(outlook.ExpiresAt, tz),
|
||||
Forecaster: outlook.Forecaster,
|
||||
Headline: outlook.Headline,
|
||||
Summary: outlook.Summary,
|
||||
Discussion: outlook.Discussion,
|
||||
SourceURL: outlook.SourceURL,
|
||||
ImageURL: outlook.ImageURL,
|
||||
ContainsLocation: outlook.ContainsLocation,
|
||||
@@ -57,6 +58,16 @@ func copyOutlook(outlook model.WeatherOutlook, tz *time.Location) model.WeatherO
|
||||
return out
|
||||
}
|
||||
|
||||
func copyOutlookDiscussion(discussion model.WeatherOutlookDiscussion, tz *time.Location) model.WeatherOutlookDiscussion {
|
||||
return model.WeatherOutlookDiscussion{
|
||||
Day: discussion.Day,
|
||||
Headline: discussion.Headline,
|
||||
Summary: discussion.Summary,
|
||||
Discussion: discussion.Discussion,
|
||||
UpdatedAt: inLocationTimePtr(discussion.UpdatedAt, tz),
|
||||
}
|
||||
}
|
||||
|
||||
func copyIntPtr(v *int) *int {
|
||||
if v == nil {
|
||||
return nil
|
||||
|
||||
@@ -337,6 +337,22 @@ func TestMetricCopyAndNilHandling(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertsPayloadPassThrough(t *testing.T) {
|
||||
run := &model.WeatherAlertRun{
|
||||
LocationID: "stl",
|
||||
AsOf: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
|
||||
Alerts: []model.WeatherAlert{{
|
||||
ID: "alert-1",
|
||||
Headline: "Storm warning",
|
||||
}},
|
||||
}
|
||||
|
||||
payload := AlertsPayload(run, UnitsUS)
|
||||
if payload != run {
|
||||
t.Fatalf("expected alerts payload to pass through input run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
|
||||
conditions := &app.CurrentConditions{
|
||||
TemperatureC: float64Ptr(20),
|
||||
@@ -440,6 +456,7 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
||||
loc := time.FixedZone("UTC-05:00", -5*60*60)
|
||||
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
|
||||
issuedAt := asOf.Add(-1 * time.Hour)
|
||||
discussionUpdatedAt := asOf.Add(-30 * time.Minute)
|
||||
severityRank := 5
|
||||
latitude := 38.627123
|
||||
longitude := -90.199456
|
||||
@@ -465,14 +482,18 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
||||
IssuedAt: issuedAt,
|
||||
ExpiresAt: asOf.Add(6 * time.Hour),
|
||||
Forecaster: "DIAL",
|
||||
Headline: "Severe storms possible",
|
||||
Summary: "Scattered severe storms are possible.",
|
||||
Discussion: "Discussion text.",
|
||||
SourceURL: "https://example.test/source",
|
||||
ImageURL: "https://example.test/image.png",
|
||||
ContainsLocation: true,
|
||||
Geometry: geometry,
|
||||
}},
|
||||
Discussions: []model.WeatherOutlookDiscussion{{
|
||||
Day: 1,
|
||||
Headline: "Severe storms possible",
|
||||
Summary: "Scattered severe storms are possible.",
|
||||
Discussion: "Discussion text.",
|
||||
UpdatedAt: &discussionUpdatedAt,
|
||||
}},
|
||||
}
|
||||
|
||||
payload := OutlookRunPayload(run, UnitsUS, loc)
|
||||
@@ -489,9 +510,15 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
||||
if len(out.Outlooks) != 1 {
|
||||
t.Fatalf("expected one outlook, got %d", len(out.Outlooks))
|
||||
}
|
||||
if len(out.Discussions) != 1 {
|
||||
t.Fatalf("expected one discussion, got %d", len(out.Discussions))
|
||||
}
|
||||
if out.Outlooks[0].SeverityRank == run.Outlooks[0].SeverityRank {
|
||||
t.Fatalf("expected severity rank pointer to be copied")
|
||||
}
|
||||
if out.Discussions[0].UpdatedAt == run.Discussions[0].UpdatedAt {
|
||||
t.Fatalf("expected discussion updatedAt pointer to be copied")
|
||||
}
|
||||
if &out.Outlooks[0].Geometry[0] == &run.Outlooks[0].Geometry[0] {
|
||||
t.Fatalf("expected geometry bytes to be copied")
|
||||
}
|
||||
@@ -502,6 +529,7 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
||||
assertOffsetSeconds(t, out.Outlooks[0].ValidTo, -5*60*60)
|
||||
assertOffsetSeconds(t, out.Outlooks[0].IssuedAt, -5*60*60)
|
||||
assertOffsetSeconds(t, out.Outlooks[0].ExpiresAt, -5*60*60)
|
||||
assertOffsetSeconds(t, *out.Discussions[0].UpdatedAt, -5*60*60)
|
||||
if !out.AsOf.UTC().Equal(asOf) || !out.Outlooks[0].ValidFrom.UTC().Equal(asOf) {
|
||||
t.Fatalf("expected timezone conversion to preserve instants")
|
||||
}
|
||||
@@ -515,12 +543,19 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
||||
if string(out.Outlooks[0].Geometry) != string(geometry) {
|
||||
t.Fatalf("expected geometry bytes preserved, got %s", out.Outlooks[0].Geometry)
|
||||
}
|
||||
if out.Discussions[0].Day != 1 || out.Discussions[0].Headline != "Severe storms possible" ||
|
||||
out.Discussions[0].Summary != "Scattered severe storms are possible." ||
|
||||
out.Discussions[0].Discussion != "Discussion text." {
|
||||
t.Fatalf("expected discussion fields preserved, got %+v", out.Discussions[0])
|
||||
}
|
||||
|
||||
*out.Latitude = 99
|
||||
*out.Longitude = -99
|
||||
*out.IssuedAt = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
*out.Outlooks[0].SeverityRank = 99
|
||||
*out.Discussions[0].UpdatedAt = time.Date(2031, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
out.Outlooks[0].Geometry[0] = '['
|
||||
out.Discussions[0].Headline = "changed"
|
||||
if *run.Latitude != latitude || *run.Longitude != longitude || !run.IssuedAt.Equal(issuedAt) {
|
||||
t.Fatalf("expected source run pointers not to mutate")
|
||||
}
|
||||
@@ -530,8 +565,15 @@ func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
|
||||
if string(run.Outlooks[0].Geometry) != string(geometry) {
|
||||
t.Fatalf("expected source geometry not to mutate, got %s", run.Outlooks[0].Geometry)
|
||||
}
|
||||
if !run.Discussions[0].UpdatedAt.Equal(discussionUpdatedAt) {
|
||||
t.Fatalf("expected source discussion updatedAt not to mutate, got %v", run.Discussions[0].UpdatedAt)
|
||||
}
|
||||
if run.Discussions[0].Headline != "Severe storms possible" {
|
||||
t.Fatalf("expected source discussion headline not to mutate, got %q", run.Discussions[0].Headline)
|
||||
}
|
||||
assertOffsetSeconds(t, run.AsOf, 0)
|
||||
assertOffsetSeconds(t, run.Outlooks[0].ValidFrom, 0)
|
||||
assertOffsetSeconds(t, *run.Discussions[0].UpdatedAt, 0)
|
||||
}
|
||||
|
||||
func float64Ptr(v float64) *float64 {
|
||||
|
||||
@@ -4,6 +4,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||
@@ -18,6 +19,7 @@ type Service interface {
|
||||
LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error)
|
||||
LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error)
|
||||
LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error)
|
||||
LatestActiveAlertRun(ctx context.Context, activeAt time.Time) (*model.WeatherAlertRun, error)
|
||||
LatestConvectiveOutlook(ctx context.Context, filter app.OutlookFilter) (*model.WeatherOutlookRun, error)
|
||||
CurrentConditions(ctx context.Context) (*app.CurrentConditions, error)
|
||||
}
|
||||
|
||||
@@ -41,9 +41,6 @@ func mapOutlookRow(row outlookRow) (model.WeatherOutlook, error) {
|
||||
IssuedAt: row.IssuedAt.UTC(),
|
||||
ExpiresAt: row.ExpiresAt.UTC(),
|
||||
Forecaster: stringValue(row.Forecaster),
|
||||
Headline: stringValue(row.Headline),
|
||||
Summary: stringValue(row.Summary),
|
||||
Discussion: stringValue(row.Discussion),
|
||||
SourceURL: stringValue(row.SourceURL),
|
||||
ImageURL: stringValue(row.ImageURL),
|
||||
ContainsLocation: row.ContainsLocation,
|
||||
@@ -51,6 +48,16 @@ func mapOutlookRow(row outlookRow) (model.WeatherOutlook, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mapOutlookDiscussionRow(row outlookDiscussionRow) model.WeatherOutlookDiscussion {
|
||||
return model.WeatherOutlookDiscussion{
|
||||
Day: row.Day,
|
||||
Headline: stringValue(row.Headline),
|
||||
Summary: stringValue(row.Summary),
|
||||
Discussion: stringValue(row.Discussion),
|
||||
UpdatedAt: timePtr(row.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(v sql.NullInt64) *int {
|
||||
if !v.Valid {
|
||||
return nil
|
||||
|
||||
@@ -79,9 +79,6 @@ func TestMapOutlookRowMapsFields(t *testing.T) {
|
||||
IssuedAt: issuedAt,
|
||||
ExpiresAt: expiresAt,
|
||||
Forecaster: sql.NullString{String: "DIAL", Valid: true},
|
||||
Headline: sql.NullString{String: "Severe storms possible", Valid: true},
|
||||
Summary: sql.NullString{String: "Scattered severe storms are possible.", Valid: true},
|
||||
Discussion: sql.NullString{String: "Discussion text.", Valid: true},
|
||||
SourceURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.html", Valid: true},
|
||||
ImageURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.gif", Valid: true},
|
||||
ContainsLocation: true,
|
||||
@@ -103,8 +100,8 @@ func TestMapOutlookRowMapsFields(t *testing.T) {
|
||||
if outlook.SeverityRank == nil || *outlook.SeverityRank != int(severityRank) {
|
||||
t.Fatalf("expected severity rank %d, got %v", severityRank, outlook.SeverityRank)
|
||||
}
|
||||
if outlook.Forecaster != "DIAL" || outlook.Headline == "" || outlook.Summary == "" || outlook.Discussion == "" {
|
||||
t.Fatalf("unexpected text fields: %+v", outlook)
|
||||
if outlook.Forecaster != "DIAL" {
|
||||
t.Fatalf("unexpected forecaster: %+v", outlook)
|
||||
}
|
||||
if outlook.SourceURL == "" || outlook.ImageURL == "" {
|
||||
t.Fatalf("expected source and image URLs: %+v", outlook)
|
||||
@@ -142,8 +139,7 @@ func TestMapOutlookRowMissingOptionals(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if outlook.LabelText != "" || outlook.Forecaster != "" || outlook.Headline != "" ||
|
||||
outlook.Summary != "" || outlook.Discussion != "" || outlook.SourceURL != "" || outlook.ImageURL != "" {
|
||||
if outlook.LabelText != "" || outlook.Forecaster != "" || outlook.SourceURL != "" || outlook.ImageURL != "" {
|
||||
t.Fatalf("expected optional strings to map to empty values, got %+v", outlook)
|
||||
}
|
||||
if outlook.SeverityRank != nil {
|
||||
@@ -172,3 +168,49 @@ func TestMapOutlookRowRejectsInvalidGeometry(t *testing.T) {
|
||||
t.Fatal("expected invalid geometry error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapOutlookDiscussionRowMapsFields(t *testing.T) {
|
||||
updatedAt := time.Date(2026, 6, 11, 7, 30, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||
|
||||
discussion := mapOutlookDiscussionRow(outlookDiscussionRow{
|
||||
DiscussionIndex: 2,
|
||||
Day: 2,
|
||||
Headline: sql.NullString{String: "Severe storms possible", Valid: true},
|
||||
Summary: sql.NullString{String: "Scattered severe storms are possible.", Valid: true},
|
||||
Discussion: sql.NullString{String: "Discussion text.", Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: updatedAt, Valid: true},
|
||||
})
|
||||
|
||||
if discussion.Day != 2 {
|
||||
t.Fatalf("expected day 2, got %d", discussion.Day)
|
||||
}
|
||||
if discussion.Headline != "Severe storms possible" {
|
||||
t.Fatalf("unexpected headline: %q", discussion.Headline)
|
||||
}
|
||||
if discussion.Summary != "Scattered severe storms are possible." {
|
||||
t.Fatalf("unexpected summary: %q", discussion.Summary)
|
||||
}
|
||||
if discussion.Discussion != "Discussion text." {
|
||||
t.Fatalf("unexpected discussion: %q", discussion.Discussion)
|
||||
}
|
||||
if discussion.UpdatedAt == nil || discussion.UpdatedAt.Location().String() != "UTC" {
|
||||
t.Fatalf("expected updatedAt UTC pointer, got %v", discussion.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapOutlookDiscussionRowMissingOptionals(t *testing.T) {
|
||||
discussion := mapOutlookDiscussionRow(outlookDiscussionRow{
|
||||
DiscussionIndex: 1,
|
||||
Day: 1,
|
||||
})
|
||||
|
||||
if discussion.Day != 1 {
|
||||
t.Fatalf("expected day 1, got %d", discussion.Day)
|
||||
}
|
||||
if discussion.Headline != "" || discussion.Summary != "" || discussion.Discussion != "" {
|
||||
t.Fatalf("expected empty optional strings, got %+v", discussion)
|
||||
}
|
||||
if discussion.UpdatedAt != nil {
|
||||
t.Fatalf("expected nil updatedAt, got %v", discussion.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +32,6 @@ SELECT
|
||||
issued_at,
|
||||
expires_at,
|
||||
forecaster,
|
||||
headline,
|
||||
summary,
|
||||
discussion,
|
||||
source_url,
|
||||
image_url,
|
||||
contains_location,
|
||||
@@ -42,4 +39,16 @@ SELECT
|
||||
FROM outlooks
|
||||
WHERE run_event_id = $1
|
||||
ORDER BY outlook_index ASC`
|
||||
|
||||
queryOutlookDiscussionsForRun = `
|
||||
SELECT
|
||||
discussion_index,
|
||||
day,
|
||||
headline,
|
||||
summary,
|
||||
discussion,
|
||||
updated_at
|
||||
FROM outlook_discussions
|
||||
WHERE run_event_id = $1
|
||||
ORDER BY discussion_index ASC`
|
||||
)
|
||||
|
||||
@@ -40,6 +40,12 @@ func (r *Repository) LatestConvectiveOutlookRun(ctx context.Context) (*model.Wea
|
||||
}
|
||||
run.Outlooks = outlooks
|
||||
|
||||
discussions, err := r.loadOutlookDiscussions(ctx, row.EventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
run.Discussions = discussions
|
||||
|
||||
return &run, nil
|
||||
}
|
||||
|
||||
@@ -68,9 +74,6 @@ func (r *Repository) loadOutlooks(ctx context.Context, eventID string) ([]model.
|
||||
&row.IssuedAt,
|
||||
&row.ExpiresAt,
|
||||
&row.Forecaster,
|
||||
&row.Headline,
|
||||
&row.Summary,
|
||||
&row.Discussion,
|
||||
&row.SourceURL,
|
||||
&row.ImageURL,
|
||||
&row.ContainsLocation,
|
||||
@@ -90,3 +93,32 @@ func (r *Repository) loadOutlooks(ctx context.Context, eventID string) ([]model.
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repository) loadOutlookDiscussions(ctx context.Context, eventID string) ([]model.WeatherOutlookDiscussion, error) {
|
||||
rows, err := r.db.QueryContext(ctx, queryOutlookDiscussionsForRun, eventID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query outlook discussions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]model.WeatherOutlookDiscussion, 0)
|
||||
for rows.Next() {
|
||||
var row outlookDiscussionRow
|
||||
if err := rows.Scan(
|
||||
&row.DiscussionIndex,
|
||||
&row.Day,
|
||||
&row.Headline,
|
||||
&row.Summary,
|
||||
&row.Discussion,
|
||||
&row.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan outlook discussion row: %w", err)
|
||||
}
|
||||
|
||||
out = append(out, mapOutlookDiscussionRow(row))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate outlook discussion rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
405
internal/adapters/outbound/postgres/outlooks_read_test.go
Normal file
405
internal/adapters/outbound/postgres/outlooks_read_test.go
Normal file
@@ -0,0 +1,405 @@
|
||||
// outlooks_read_test.go validates outlook repository read flow.
|
||||
// Layer: adapters/outbound/postgres outlook read tests.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const outlookTestDriverName = "weatherapi_outlook_read_test"
|
||||
|
||||
func init() {
|
||||
sql.Register(outlookTestDriverName, outlookTestDriver{})
|
||||
}
|
||||
|
||||
func TestLatestConvectiveOutlookRunLoadsOutlooksAndDiscussions(t *testing.T) {
|
||||
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
|
||||
issuedAt := asOf.Add(-1 * time.Hour)
|
||||
discussionUpdated := asOf.Add(-30 * time.Minute)
|
||||
repo, closeDB := openOutlookTestRepository(t,
|
||||
outlookParentQuery([][]driver.Value{{
|
||||
"evt-outlook-run",
|
||||
"stl",
|
||||
"St. Louis",
|
||||
float64(38.62),
|
||||
float64(-90.2),
|
||||
asOf,
|
||||
issuedAt,
|
||||
}}),
|
||||
outlookRowsQuery([][]driver.Value{
|
||||
outlookReadRow(1, "day-1", 1, "categorical"),
|
||||
outlookReadRow(2, "day-2", 2, "wind"),
|
||||
}, nil),
|
||||
outlookDiscussionsQuery([][]driver.Value{
|
||||
{int64(1), int64(1), "Day 1 headline", "Day 1 summary", "Day 1 discussion", discussionUpdated},
|
||||
{int64(2), int64(2), "Day 2 headline", nil, "Day 2 discussion", nil},
|
||||
}, nil),
|
||||
)
|
||||
defer closeDB()
|
||||
|
||||
run, err := repo.LatestConvectiveOutlookRun(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if run == nil {
|
||||
t.Fatal("expected outlook run")
|
||||
}
|
||||
if run.LocationID != "stl" || run.LocationName != "St. Louis" {
|
||||
t.Fatalf("unexpected run metadata: %+v", run)
|
||||
}
|
||||
if len(run.Outlooks) != 2 {
|
||||
t.Fatalf("expected 2 outlooks, got %d", len(run.Outlooks))
|
||||
}
|
||||
if run.Outlooks[0].ID != "day-1" || run.Outlooks[1].ID != "day-2" {
|
||||
t.Fatalf("expected outlook order from rows, got %+v", run.Outlooks)
|
||||
}
|
||||
if len(run.Discussions) != 2 {
|
||||
t.Fatalf("expected 2 discussions, got %d", len(run.Discussions))
|
||||
}
|
||||
if run.Discussions[0].Day != 1 || run.Discussions[0].Headline != "Day 1 headline" {
|
||||
t.Fatalf("unexpected first discussion: %+v", run.Discussions[0])
|
||||
}
|
||||
if run.Discussions[1].Day != 2 || run.Discussions[1].Summary != "" {
|
||||
t.Fatalf("unexpected second discussion: %+v", run.Discussions[1])
|
||||
}
|
||||
if run.Discussions[0].UpdatedAt == nil || run.Discussions[0].UpdatedAt.Location().String() != "UTC" {
|
||||
t.Fatalf("expected discussion updatedAt UTC pointer, got %v", run.Discussions[0].UpdatedAt)
|
||||
}
|
||||
if run.Discussions[1].UpdatedAt != nil {
|
||||
t.Fatalf("expected nil discussion updatedAt, got %v", run.Discussions[1].UpdatedAt)
|
||||
}
|
||||
assertOutlookTestQueriesConsumed(t)
|
||||
}
|
||||
|
||||
func TestLatestConvectiveOutlookRunMissingParentReturnsNil(t *testing.T) {
|
||||
repo, closeDB := openOutlookTestRepository(t, outlookParentQuery(nil))
|
||||
defer closeDB()
|
||||
|
||||
run, err := repo.LatestConvectiveOutlookRun(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if run != nil {
|
||||
t.Fatalf("expected nil run, got %+v", run)
|
||||
}
|
||||
assertOutlookTestQueriesConsumed(t)
|
||||
}
|
||||
|
||||
func TestLatestConvectiveOutlookRunWrapsReadErrors(t *testing.T) {
|
||||
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
queries []scriptedOutlookQuery
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "parent query",
|
||||
queries: []scriptedOutlookQuery{
|
||||
{name: "parent", query: queryLatestConvectiveOutlookRun, err: errors.New("parent unavailable")},
|
||||
},
|
||||
want: "query latest convective outlook run",
|
||||
},
|
||||
{
|
||||
name: "outlooks query",
|
||||
queries: []scriptedOutlookQuery{
|
||||
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||
{name: "outlooks", query: queryOutlooksForRun, args: []driver.Value{"evt-outlook-run"}, err: errors.New("outlooks unavailable")},
|
||||
},
|
||||
want: "query outlooks",
|
||||
},
|
||||
{
|
||||
name: "outlook scan",
|
||||
queries: []scriptedOutlookQuery{
|
||||
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||
outlookRowsQuery([][]driver.Value{
|
||||
{int64(1), "day-1"},
|
||||
}, nil),
|
||||
},
|
||||
want: "scan outlook row",
|
||||
},
|
||||
{
|
||||
name: "outlook map",
|
||||
queries: []scriptedOutlookQuery{
|
||||
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||
outlookRowsQuery([][]driver.Value{
|
||||
outlookReadRowWithGeometry(1, "day-1", 1, "categorical", `{"type":"Point"`),
|
||||
}, nil),
|
||||
},
|
||||
want: "map outlook row",
|
||||
},
|
||||
{
|
||||
name: "outlook iteration",
|
||||
queries: []scriptedOutlookQuery{
|
||||
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||
outlookRowsQuery([][]driver.Value{
|
||||
outlookReadRow(1, "day-1", 1, "categorical"),
|
||||
}, errors.New("outlook iteration failed")),
|
||||
},
|
||||
want: "iterate outlook rows",
|
||||
},
|
||||
{
|
||||
name: "discussions query",
|
||||
queries: []scriptedOutlookQuery{
|
||||
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||
outlookRowsQuery(nil, nil),
|
||||
{name: "discussions", query: queryOutlookDiscussionsForRun, args: []driver.Value{"evt-outlook-run"}, err: errors.New("discussions unavailable")},
|
||||
},
|
||||
want: "query outlook discussions",
|
||||
},
|
||||
{
|
||||
name: "discussion scan",
|
||||
queries: []scriptedOutlookQuery{
|
||||
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||
outlookRowsQuery(nil, nil),
|
||||
outlookDiscussionsQuery([][]driver.Value{
|
||||
{int64(1), "bad day", nil, nil, nil, nil},
|
||||
}, nil),
|
||||
},
|
||||
want: "scan outlook discussion row",
|
||||
},
|
||||
{
|
||||
name: "discussion iteration",
|
||||
queries: []scriptedOutlookQuery{
|
||||
outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}),
|
||||
outlookRowsQuery(nil, nil),
|
||||
outlookDiscussionsQuery([][]driver.Value{
|
||||
{int64(1), int64(1), nil, nil, nil, nil},
|
||||
}, errors.New("discussion iteration failed")),
|
||||
},
|
||||
want: "iterate outlook discussion rows",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repo, closeDB := openOutlookTestRepository(t, tt.queries...)
|
||||
defer closeDB()
|
||||
|
||||
_, err := repo.LatestConvectiveOutlookRun(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func openOutlookTestRepository(t *testing.T, queries ...scriptedOutlookQuery) (*Repository, func()) {
|
||||
t.Helper()
|
||||
outlookTestScript.set(queries)
|
||||
|
||||
db, err := sql.Open(outlookTestDriverName, "")
|
||||
if err != nil {
|
||||
t.Fatalf("open test db: %v", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
return NewRepository(db), func() {
|
||||
_ = db.Close()
|
||||
outlookTestScript.set(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func assertOutlookTestQueriesConsumed(t *testing.T) {
|
||||
t.Helper()
|
||||
if remaining := outlookTestScript.remaining(); remaining != 0 {
|
||||
t.Fatalf("expected all scripted queries consumed, got %d remaining", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func outlookParentQuery(rows [][]driver.Value) scriptedOutlookQuery {
|
||||
return scriptedOutlookQuery{
|
||||
name: "parent",
|
||||
query: queryLatestConvectiveOutlookRun,
|
||||
columns: []string{"event_id", "location_id", "location_name", "latitude", "longitude", "as_of", "issued_at"},
|
||||
rows: rows,
|
||||
}
|
||||
}
|
||||
|
||||
func outlookRowsQuery(rows [][]driver.Value, nextErr error) scriptedOutlookQuery {
|
||||
return scriptedOutlookQuery{
|
||||
name: "outlooks",
|
||||
query: queryOutlooksForRun,
|
||||
args: []driver.Value{"evt-outlook-run"},
|
||||
columns: []string{"outlook_index", "outlook_id", "provider", "product", "day", "outlook_type", "label", "label_text", "severity_rank", "valid_from", "valid_to", "issued_at", "expires_at", "forecaster", "source_url", "image_url", "contains_location", "geometry_json"},
|
||||
rows: rows,
|
||||
nextErr: nextErr,
|
||||
}
|
||||
}
|
||||
|
||||
func outlookDiscussionsQuery(rows [][]driver.Value, nextErr error) scriptedOutlookQuery {
|
||||
return scriptedOutlookQuery{
|
||||
name: "discussions",
|
||||
query: queryOutlookDiscussionsForRun,
|
||||
args: []driver.Value{"evt-outlook-run"},
|
||||
columns: []string{"discussion_index", "day", "headline", "summary", "discussion", "updated_at"},
|
||||
rows: rows,
|
||||
nextErr: nextErr,
|
||||
}
|
||||
}
|
||||
|
||||
func outlookReadRow(index int64, id string, day int64, outlookType string) []driver.Value {
|
||||
return outlookReadRowWithGeometry(index, id, day, outlookType, `{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,38]]]}`)
|
||||
}
|
||||
|
||||
func outlookReadRowWithGeometry(index int64, id string, day int64, outlookType string, geometry string) []driver.Value {
|
||||
validFrom := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
||||
validTo := validFrom.Add(6 * time.Hour)
|
||||
return []driver.Value{
|
||||
index,
|
||||
id,
|
||||
"spc",
|
||||
"convective",
|
||||
day,
|
||||
outlookType,
|
||||
"SLGT",
|
||||
"Slight Risk",
|
||||
int64(5),
|
||||
validFrom,
|
||||
validTo,
|
||||
validFrom.Add(-1 * time.Hour),
|
||||
validTo,
|
||||
"DIAL",
|
||||
"https://example.test/source",
|
||||
"https://example.test/image.png",
|
||||
true,
|
||||
geometry,
|
||||
}
|
||||
}
|
||||
|
||||
type outlookTestDriver struct{}
|
||||
|
||||
func (outlookTestDriver) Open(string) (driver.Conn, error) {
|
||||
return outlookTestConn{}, nil
|
||||
}
|
||||
|
||||
type outlookTestConn struct{}
|
||||
|
||||
func (outlookTestConn) Prepare(string) (driver.Stmt, error) {
|
||||
return nil, errors.New("prepare is not supported")
|
||||
}
|
||||
|
||||
func (outlookTestConn) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (outlookTestConn) Begin() (driver.Tx, error) {
|
||||
return nil, errors.New("transactions are not supported")
|
||||
}
|
||||
|
||||
func (outlookTestConn) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
|
||||
return outlookTestScript.next(query, args)
|
||||
}
|
||||
|
||||
type scriptedOutlookQuery struct {
|
||||
name string
|
||||
query string
|
||||
args []driver.Value
|
||||
columns []string
|
||||
rows [][]driver.Value
|
||||
err error
|
||||
nextErr error
|
||||
}
|
||||
|
||||
type outlookTestScriptState struct {
|
||||
mu sync.Mutex
|
||||
queries []scriptedOutlookQuery
|
||||
}
|
||||
|
||||
var outlookTestScript outlookTestScriptState
|
||||
|
||||
func (s *outlookTestScriptState) set(queries []scriptedOutlookQuery) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.queries = append([]scriptedOutlookQuery(nil), queries...)
|
||||
}
|
||||
|
||||
func (s *outlookTestScriptState) remaining() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.queries)
|
||||
}
|
||||
|
||||
func (s *outlookTestScriptState) next(query string, args []driver.NamedValue) (driver.Rows, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if len(s.queries) == 0 {
|
||||
return nil, fmt.Errorf("unexpected query: %s", compactSQL(query))
|
||||
}
|
||||
next := s.queries[0]
|
||||
s.queries = s.queries[1:]
|
||||
|
||||
if compactSQL(query) != compactSQL(next.query) {
|
||||
return nil, fmt.Errorf("expected %s query %q, got %q", next.name, compactSQL(next.query), compactSQL(query))
|
||||
}
|
||||
if len(args) != len(next.args) {
|
||||
return nil, fmt.Errorf("expected %s args %v, got %v", next.name, next.args, namedValues(args))
|
||||
}
|
||||
for i, arg := range args {
|
||||
if arg.Value != next.args[i] {
|
||||
return nil, fmt.Errorf("expected %s arg %d to be %v, got %v", next.name, i, next.args[i], arg.Value)
|
||||
}
|
||||
}
|
||||
if next.err != nil {
|
||||
return nil, next.err
|
||||
}
|
||||
return &outlookTestRows{
|
||||
columns: append([]string(nil), next.columns...),
|
||||
rows: append([][]driver.Value(nil), next.rows...),
|
||||
nextErr: next.nextErr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type outlookTestRows struct {
|
||||
columns []string
|
||||
rows [][]driver.Value
|
||||
index int
|
||||
nextErr error
|
||||
}
|
||||
|
||||
func (r *outlookTestRows) Columns() []string {
|
||||
return r.columns
|
||||
}
|
||||
|
||||
func (r *outlookTestRows) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *outlookTestRows) Next(dest []driver.Value) error {
|
||||
if r.index >= len(r.rows) {
|
||||
if r.nextErr != nil {
|
||||
err := r.nextErr
|
||||
r.nextErr = nil
|
||||
return err
|
||||
}
|
||||
return io.EOF
|
||||
}
|
||||
copy(dest, r.rows[r.index])
|
||||
r.index++
|
||||
return nil
|
||||
}
|
||||
|
||||
func compactSQL(query string) string {
|
||||
return strings.Join(strings.Fields(query), " ")
|
||||
}
|
||||
|
||||
func namedValues(args []driver.NamedValue) []driver.Value {
|
||||
out := make([]driver.Value, len(args))
|
||||
for i := range args {
|
||||
out[i] = args[i].Value
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -32,11 +32,17 @@ type outlookRow struct {
|
||||
IssuedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
Forecaster sql.NullString
|
||||
Headline sql.NullString
|
||||
Summary sql.NullString
|
||||
Discussion sql.NullString
|
||||
SourceURL sql.NullString
|
||||
ImageURL sql.NullString
|
||||
ContainsLocation bool
|
||||
GeometryJSON string
|
||||
}
|
||||
|
||||
type outlookDiscussionRow struct {
|
||||
DiscussionIndex int
|
||||
Day int
|
||||
Headline sql.NullString
|
||||
Summary sql.NullString
|
||||
Discussion sql.NullString
|
||||
UpdatedAt sql.NullTime
|
||||
}
|
||||
|
||||
@@ -68,6 +68,26 @@ func (s *Service) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, e
|
||||
return s.repo.LatestAlertRun(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) LatestActiveAlertRun(ctx context.Context, activeAt time.Time) (*model.WeatherAlertRun, error) {
|
||||
run, err := s.repo.LatestAlertRun(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if run == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
out := cloneAlertRun(run)
|
||||
alerts := out.Alerts[:0]
|
||||
for _, alert := range out.Alerts {
|
||||
if isActiveAlert(alert, activeAt) {
|
||||
alerts = append(alerts, alert)
|
||||
}
|
||||
}
|
||||
out.Alerts = alerts
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) LatestConvectiveOutlook(ctx context.Context, filter OutlookFilter) (*model.WeatherOutlookRun, error) {
|
||||
run, err := s.repo.LatestConvectiveOutlookRun(ctx)
|
||||
if err != nil {
|
||||
@@ -85,6 +105,7 @@ func (s *Service) LatestConvectiveOutlook(ctx context.Context, filter OutlookFil
|
||||
}
|
||||
}
|
||||
out.Outlooks = outlooks
|
||||
out.Discussions = filterOutlookDiscussions(out.Discussions, out.Outlooks)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -112,6 +133,53 @@ func normalizeOutlookType(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func cloneAlertRun(run *model.WeatherAlertRun) *model.WeatherAlertRun {
|
||||
out := *run
|
||||
out.Latitude = copyFloat64(run.Latitude)
|
||||
out.Longitude = copyFloat64(run.Longitude)
|
||||
if run.Alerts != nil {
|
||||
out.Alerts = make([]model.WeatherAlert, len(run.Alerts))
|
||||
for i := range run.Alerts {
|
||||
out.Alerts[i] = cloneAlert(run.Alerts[i])
|
||||
}
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
func cloneAlert(alert model.WeatherAlert) model.WeatherAlert {
|
||||
out := alert
|
||||
out.Sent = copyTime(alert.Sent)
|
||||
out.Effective = copyTime(alert.Effective)
|
||||
out.Onset = copyTime(alert.Onset)
|
||||
out.Expires = copyTime(alert.Expires)
|
||||
if alert.References != nil {
|
||||
out.References = make([]model.AlertReference, len(alert.References))
|
||||
for i := range alert.References {
|
||||
out.References[i] = cloneAlertReference(alert.References[i])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneAlertReference(ref model.AlertReference) model.AlertReference {
|
||||
out := ref
|
||||
out.Sent = copyTime(ref.Sent)
|
||||
return out
|
||||
}
|
||||
|
||||
func isActiveAlert(alert model.WeatherAlert, activeAt time.Time) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(alert.MessageType), "Cancel") {
|
||||
return false
|
||||
}
|
||||
if alert.Effective != nil && activeAt.Before(*alert.Effective) {
|
||||
return false
|
||||
}
|
||||
if alert.Expires != nil && !activeAt.Before(*alert.Expires) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
|
||||
out := *run
|
||||
out.Latitude = copyFloat64(run.Latitude)
|
||||
@@ -123,6 +191,12 @@ func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
|
||||
out.Outlooks[i] = cloneOutlook(run.Outlooks[i])
|
||||
}
|
||||
}
|
||||
if run.Discussions != nil {
|
||||
out.Discussions = make([]model.WeatherOutlookDiscussion, len(run.Discussions))
|
||||
for i := range run.Discussions {
|
||||
out.Discussions[i] = cloneOutlookDiscussion(run.Discussions[i])
|
||||
}
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
@@ -135,6 +209,31 @@ func cloneOutlook(outlook model.WeatherOutlook) model.WeatherOutlook {
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneOutlookDiscussion(discussion model.WeatherOutlookDiscussion) model.WeatherOutlookDiscussion {
|
||||
out := discussion
|
||||
out.UpdatedAt = copyTime(discussion.UpdatedAt)
|
||||
return out
|
||||
}
|
||||
|
||||
func filterOutlookDiscussions(discussions []model.WeatherOutlookDiscussion, outlooks []model.WeatherOutlook) []model.WeatherOutlookDiscussion {
|
||||
if len(outlooks) == 0 {
|
||||
return []model.WeatherOutlookDiscussion{}
|
||||
}
|
||||
|
||||
retainedDays := make(map[int]struct{}, len(outlooks))
|
||||
for _, outlook := range outlooks {
|
||||
retainedDays[outlook.Day] = struct{}{}
|
||||
}
|
||||
|
||||
out := discussions[:0]
|
||||
for _, discussion := range discussions {
|
||||
if _, ok := retainedDays[discussion.Day]; ok {
|
||||
out = append(out, discussion)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func copyFloat64(value *float64) *float64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
|
||||
@@ -5,6 +5,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -24,6 +25,7 @@ type fakeRepository struct {
|
||||
err error
|
||||
|
||||
currentConditionsWindow int
|
||||
alertRunCalls int
|
||||
outlookRunCalls int
|
||||
}
|
||||
|
||||
@@ -52,6 +54,7 @@ func (r *fakeRepository) LatestWeatherStory(context.Context) (*model.WeatherStor
|
||||
}
|
||||
|
||||
func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
|
||||
r.alertRunCalls++
|
||||
return r.alerts, r.err
|
||||
}
|
||||
|
||||
@@ -117,6 +120,137 @@ func TestServiceDelegatesAlerts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceLatestActiveAlertRunDelegatesAndFilters(t *testing.T) {
|
||||
activeAt := testTime(12)
|
||||
repo := &fakeRepository{alerts: testAlertRun()}
|
||||
svc := NewService(repo)
|
||||
|
||||
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if repo.alertRunCalls != 1 {
|
||||
t.Fatalf("expected one repository call, got %d", repo.alertRunCalls)
|
||||
}
|
||||
assertAlertIDs(t, run, []string{"current", "effective-at-boundary", "missing-effective", "missing-expires", "later-onset"})
|
||||
}
|
||||
|
||||
func TestServiceLatestActiveAlertRunNoData(t *testing.T) {
|
||||
repo := &fakeRepository{}
|
||||
svc := NewService(repo)
|
||||
|
||||
run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if run != nil {
|
||||
t.Fatalf("expected nil alert run, got %+v", run)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceLatestActiveAlertRunPropagatesErrors(t *testing.T) {
|
||||
want := errors.New("alert read failed")
|
||||
repo := &fakeRepository{err: want}
|
||||
svc := NewService(repo)
|
||||
|
||||
run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12))
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("expected error %v, got %v", want, err)
|
||||
}
|
||||
if run != nil {
|
||||
t.Fatalf("expected nil alert run on error, got %+v", run)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceLatestActiveAlertRunKeepsMetadataWithEmptyAlerts(t *testing.T) {
|
||||
activeAt := testTime(12)
|
||||
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{
|
||||
testAlert("expired", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12)),
|
||||
testAlert("cancel", "Cancel", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)),
|
||||
testAlert("future", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(14)),
|
||||
})}
|
||||
svc := NewService(repo)
|
||||
|
||||
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if run == nil {
|
||||
t.Fatal("expected alert run")
|
||||
}
|
||||
if run.LocationID != "stl" || run.LocationName != "St. Louis" || !run.AsOf.Equal(testTime(10)) {
|
||||
t.Fatalf("unexpected run metadata: %+v", run)
|
||||
}
|
||||
if run.Latitude == nil || *run.Latitude != 38.62 {
|
||||
t.Fatalf("unexpected latitude: %v", run.Latitude)
|
||||
}
|
||||
if run.Longitude == nil || *run.Longitude != -90.2 {
|
||||
t.Fatalf("unexpected longitude: %v", run.Longitude)
|
||||
}
|
||||
if run.Alerts == nil {
|
||||
t.Fatal("expected empty alert slice, got nil")
|
||||
}
|
||||
if len(run.Alerts) != 0 {
|
||||
t.Fatalf("expected no alerts, got %+v", run.Alerts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceLatestActiveAlertRunDoesNotMutateRepositoryRun(t *testing.T) {
|
||||
original := testAlertRun()
|
||||
repo := &fakeRepository{alerts: original}
|
||||
svc := NewService(repo)
|
||||
|
||||
run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(run.Alerts) == 0 {
|
||||
t.Fatal("expected active alerts")
|
||||
}
|
||||
|
||||
*run.Latitude = 99
|
||||
*run.Longitude = -99
|
||||
*run.Alerts[0].Sent = testTime(1)
|
||||
*run.Alerts[0].Effective = testTime(2)
|
||||
*run.Alerts[0].Onset = testTime(3)
|
||||
*run.Alerts[0].Expires = testTime(4)
|
||||
*run.Alerts[0].References[0].Sent = testTime(5)
|
||||
run.Alerts[0].ID = "changed"
|
||||
run.Alerts[0].References[0].ID = "changed"
|
||||
run.Alerts = run.Alerts[:1]
|
||||
|
||||
if *original.Latitude != 38.62 {
|
||||
t.Fatalf("expected original latitude unchanged, got %v", *original.Latitude)
|
||||
}
|
||||
if *original.Longitude != -90.2 {
|
||||
t.Fatalf("expected original longitude unchanged, got %v", *original.Longitude)
|
||||
}
|
||||
if original.Alerts[0].ID != "current" {
|
||||
t.Fatalf("expected original alert ID unchanged, got %q", original.Alerts[0].ID)
|
||||
}
|
||||
if original.Alerts[0].Sent == nil || !original.Alerts[0].Sent.Equal(testTime(9)) {
|
||||
t.Fatalf("expected original sent unchanged, got %v", original.Alerts[0].Sent)
|
||||
}
|
||||
if original.Alerts[0].Effective == nil || !original.Alerts[0].Effective.Equal(testTime(10)) {
|
||||
t.Fatalf("expected original effective unchanged, got %v", original.Alerts[0].Effective)
|
||||
}
|
||||
if original.Alerts[0].Onset == nil || !original.Alerts[0].Onset.Equal(testTime(11)) {
|
||||
t.Fatalf("expected original onset unchanged, got %v", original.Alerts[0].Onset)
|
||||
}
|
||||
if original.Alerts[0].Expires == nil || !original.Alerts[0].Expires.Equal(testTime(13)) {
|
||||
t.Fatalf("expected original expires unchanged, got %v", original.Alerts[0].Expires)
|
||||
}
|
||||
if original.Alerts[0].References[0].ID != "ref-current" {
|
||||
t.Fatalf("expected original reference ID unchanged, got %q", original.Alerts[0].References[0].ID)
|
||||
}
|
||||
if original.Alerts[0].References[0].Sent == nil || !original.Alerts[0].References[0].Sent.Equal(testTime(8)) {
|
||||
t.Fatalf("expected original reference sent unchanged, got %v", original.Alerts[0].References[0].Sent)
|
||||
}
|
||||
if len(original.Alerts) != 8 {
|
||||
t.Fatalf("expected original alert slice unchanged, got %d entries", len(original.Alerts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
|
||||
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
||||
svc := NewService(repo)
|
||||
@@ -131,6 +265,7 @@ func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
|
||||
if run == nil || run.LocationID != "stl" {
|
||||
t.Fatalf("unexpected outlook run: %+v", run)
|
||||
}
|
||||
assertDiscussionDays(t, run, []int{1, 2})
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookNoData(t *testing.T) {
|
||||
@@ -208,6 +343,7 @@ func TestServiceLatestConvectiveOutlookFiltersByDay(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, run, []string{"day-2"})
|
||||
assertDiscussionDays(t, run, []int{2})
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookFiltersByOutlookType(t *testing.T) {
|
||||
@@ -219,6 +355,7 @@ func TestServiceLatestConvectiveOutlookFiltersByOutlookType(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, run, []string{"tor-1"})
|
||||
assertDiscussionDays(t, run, []int{1})
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookFiltersByContainsLocation(t *testing.T) {
|
||||
@@ -230,7 +367,21 @@ func TestServiceLatestConvectiveOutlookFiltersByContainsLocation(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, run, []string{"cat-1", "tor-1"})
|
||||
assertOutlookIDs(t, run, []string{"cat-1", "tor-1", "day-2"})
|
||||
assertDiscussionDays(t, run, []int{1, 2})
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookContainsLocationFalseReturnsEmptyRun(t *testing.T) {
|
||||
containsLocation := false
|
||||
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
||||
svc := NewService(repo)
|
||||
|
||||
run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ContainsLocation: &containsLocation})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, run, nil)
|
||||
assertDiscussionDays(t, run, nil)
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookFiltersByActiveAt(t *testing.T) {
|
||||
@@ -243,6 +394,7 @@ func TestServiceLatestConvectiveOutlookFiltersByActiveAt(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, run, []string{"cat-1", "tor-1"})
|
||||
assertDiscussionDays(t, run, []int{1})
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) {
|
||||
@@ -262,6 +414,7 @@ func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, run, []string{"cat-1"})
|
||||
assertDiscussionDays(t, run, []int{1})
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookActiveAtBoundary(t *testing.T) {
|
||||
@@ -277,12 +430,14 @@ func TestServiceLatestConvectiveOutlookActiveAtBoundary(t *testing.T) {
|
||||
t.Fatalf("unexpected validFrom error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, fromRun, []string{"cat-1"})
|
||||
assertDiscussionDays(t, fromRun, []int{1})
|
||||
|
||||
toRun, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ActiveAt: &validTo})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected validTo error: %v", err)
|
||||
}
|
||||
assertOutlookIDs(t, toRun, nil)
|
||||
assertDiscussionDays(t, toRun, nil)
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookKeepsRunMetadataWithEmptyOutlooks(t *testing.T) {
|
||||
@@ -306,6 +461,12 @@ func TestServiceLatestConvectiveOutlookKeepsRunMetadataWithEmptyOutlooks(t *test
|
||||
if len(run.Outlooks) != 0 {
|
||||
t.Fatalf("expected no outlooks, got %+v", run.Outlooks)
|
||||
}
|
||||
if run.Discussions == nil {
|
||||
t.Fatal("expected empty discussions slice, got nil")
|
||||
}
|
||||
if len(run.Discussions) != 0 {
|
||||
t.Fatalf("expected no discussions, got %+v", run.Discussions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T) {
|
||||
@@ -325,9 +486,12 @@ func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T)
|
||||
*run.Longitude = -99
|
||||
*run.IssuedAt = testTime(99)
|
||||
*run.Outlooks[0].SeverityRank = 99
|
||||
*run.Discussions[0].UpdatedAt = testTime(98)
|
||||
run.Outlooks[0].Geometry[0] = '{'
|
||||
run.Outlooks[0].ID = "changed"
|
||||
run.Discussions[0].Headline = "changed"
|
||||
run.Outlooks = run.Outlooks[:1]
|
||||
run.Discussions = run.Discussions[:1]
|
||||
|
||||
if *original.Latitude != 38.62 {
|
||||
t.Fatalf("expected original latitude unchanged, got %v", *original.Latitude)
|
||||
@@ -350,6 +514,15 @@ func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T)
|
||||
if len(original.Outlooks) != 3 {
|
||||
t.Fatalf("expected original outlook slice unchanged, got %d entries", len(original.Outlooks))
|
||||
}
|
||||
if original.Discussions[0].UpdatedAt == nil || !original.Discussions[0].UpdatedAt.Equal(testTime(10)) {
|
||||
t.Fatalf("expected original discussion updatedAt unchanged, got %v", original.Discussions[0].UpdatedAt)
|
||||
}
|
||||
if original.Discussions[0].Headline != "Day 1 headline" {
|
||||
t.Fatalf("expected original discussion headline unchanged, got %q", original.Discussions[0].Headline)
|
||||
}
|
||||
if len(original.Discussions) != 3 {
|
||||
t.Fatalf("expected original discussion slice unchanged, got %d entries", len(original.Discussions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePropagatesErrors(t *testing.T) {
|
||||
@@ -362,6 +535,67 @@ func TestServicePropagatesErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func testAlertRun() *model.WeatherAlertRun {
|
||||
return testAlertRunWithAlerts([]model.WeatherAlert{
|
||||
testAlert("current", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)),
|
||||
testAlert("expired", "Update", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12)),
|
||||
testAlert("future-effective", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(15)),
|
||||
testAlert("canceled", " cancel ", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)),
|
||||
testAlert("effective-at-boundary", "Alert", testTimePtr(9), testTimePtr(12), testTimePtr(12), testTimePtr(14)),
|
||||
testAlert("missing-effective", "Alert", testTimePtr(9), nil, nil, testTimePtr(14)),
|
||||
testAlert("missing-expires", "Alert", testTimePtr(9), testTimePtr(10), nil, nil),
|
||||
testAlert("later-onset", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(13), testTimePtr(14)),
|
||||
})
|
||||
}
|
||||
|
||||
func testAlertRunWithAlerts(alerts []model.WeatherAlert) *model.WeatherAlertRun {
|
||||
latitude := 38.62
|
||||
longitude := -90.2
|
||||
return &model.WeatherAlertRun{
|
||||
LocationID: "stl",
|
||||
LocationName: "St. Louis",
|
||||
AsOf: testTime(10),
|
||||
Latitude: &latitude,
|
||||
Longitude: &longitude,
|
||||
Alerts: alerts,
|
||||
}
|
||||
}
|
||||
|
||||
func testAlert(id string, messageType string, sent *time.Time, effective *time.Time, onset *time.Time, expires *time.Time) model.WeatherAlert {
|
||||
refSent := testTime(8)
|
||||
return model.WeatherAlert{
|
||||
ID: id,
|
||||
Event: "Thunderstorm Warning",
|
||||
Headline: "Storm headline",
|
||||
Severity: "Severe",
|
||||
Urgency: "Immediate",
|
||||
Certainty: "Likely",
|
||||
Status: "Actual",
|
||||
MessageType: messageType,
|
||||
Category: "Met",
|
||||
Response: "Shelter",
|
||||
Description: "Storm description",
|
||||
Instruction: "Take shelter",
|
||||
Sent: sent,
|
||||
Effective: effective,
|
||||
Onset: onset,
|
||||
Expires: expires,
|
||||
AreaDescription: "St. Louis City",
|
||||
SenderName: "NWS St. Louis",
|
||||
References: []model.AlertReference{{
|
||||
ID: "ref-" + id,
|
||||
Identifier: "identifier-" + id,
|
||||
Sender: "sender-" + id,
|
||||
Sent: &refSent,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func testTimePtr(hour int) *time.Time {
|
||||
value := testTime(hour)
|
||||
return &value
|
||||
}
|
||||
|
||||
func testOutlookRun() *model.WeatherOutlookRun {
|
||||
latitude := 38.62
|
||||
longitude := -90.2
|
||||
@@ -376,8 +610,25 @@ func testOutlookRun() *model.WeatherOutlookRun {
|
||||
Outlooks: []model.WeatherOutlook{
|
||||
testOutlook("cat-1", 1, "categorical", true, testTime(12), testTime(18), 5, `["cat"]`),
|
||||
testOutlook("tor-1", 1, "tornado", true, testTime(13), testTime(19), 7, `["tor"]`),
|
||||
testOutlook("day-2", 2, "wind", false, testTime(18), testTime(24), 2, `["wind"]`),
|
||||
testOutlook("day-2", 2, "wind", true, testTime(18), testTime(24), 2, `["wind"]`),
|
||||
},
|
||||
Discussions: []model.WeatherOutlookDiscussion{
|
||||
testOutlookDiscussion(1),
|
||||
testOutlookDiscussion(2),
|
||||
testOutlookDiscussion(3),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testOutlookDiscussion(day int) model.WeatherOutlookDiscussion {
|
||||
updatedAt := testTime(9 + day)
|
||||
dayText := strconv.Itoa(day)
|
||||
return model.WeatherOutlookDiscussion{
|
||||
Day: day,
|
||||
Headline: "Day " + dayText + " headline",
|
||||
Summary: "Day " + dayText + " summary",
|
||||
Discussion: "Day " + dayText + " discussion",
|
||||
UpdatedAt: &updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,9 +647,6 @@ func testOutlook(id string, day int, outlookType string, containsLocation bool,
|
||||
IssuedAt: validFrom.Add(-time.Hour),
|
||||
ExpiresAt: validTo,
|
||||
Forecaster: "DIAL",
|
||||
Headline: id + " headline",
|
||||
Summary: id + " summary",
|
||||
Discussion: id + " discussion",
|
||||
SourceURL: "https://example.test/" + id,
|
||||
ImageURL: "https://example.test/" + id + ".png",
|
||||
ContainsLocation: containsLocation,
|
||||
@@ -424,3 +672,33 @@ func assertOutlookIDs(t *testing.T, run *model.WeatherOutlookRun, want []string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertDiscussionDays(t *testing.T, run *model.WeatherOutlookRun, want []int) {
|
||||
t.Helper()
|
||||
if run == nil {
|
||||
t.Fatal("expected outlook run")
|
||||
}
|
||||
if len(run.Discussions) != len(want) {
|
||||
t.Fatalf("expected discussion days %v, got %+v", want, run.Discussions)
|
||||
}
|
||||
for i := range want {
|
||||
if run.Discussions[i].Day != want[i] {
|
||||
t.Fatalf("expected discussion days %v, got %+v", want, run.Discussions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertAlertIDs(t *testing.T, run *model.WeatherAlertRun, want []string) {
|
||||
t.Helper()
|
||||
if run == nil {
|
||||
t.Fatal("expected alert run")
|
||||
}
|
||||
if len(run.Alerts) != len(want) {
|
||||
t.Fatalf("expected alert IDs %v, got %+v", want, run.Alerts)
|
||||
}
|
||||
for i := range want {
|
||||
if run.Alerts[i].ID != want[i] {
|
||||
t.Fatalf("expected alert IDs %v, got %+v", want, run.Alerts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,16 +15,28 @@ Contains Location: {{$outlook.ContainsLocation}}
|
||||
{{- if $outlook.LabelText}}
|
||||
Label Text: {{$outlook.LabelText}}
|
||||
{{- end}}
|
||||
{{- if $outlook.Headline}}
|
||||
Headline: {{$outlook.Headline}}
|
||||
{{- end}}
|
||||
{{- if $outlook.Summary}}
|
||||
Summary: {{$outlook.Summary}}
|
||||
{{- end}}
|
||||
{{- if $outlook.SourceURL}}
|
||||
Source URL: {{$outlook.SourceURL}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
Discussions: {{len .Data.Discussions}}
|
||||
{{- range $i, $discussion := .Data.Discussions}}
|
||||
|
||||
[{{$i}}] Day {{$discussion.Day}} Discussion
|
||||
{{- if $discussion.UpdatedAt}}
|
||||
Updated At: {{$discussion.UpdatedAt}}
|
||||
{{- end}}
|
||||
{{- if $discussion.Headline}}
|
||||
Headline: {{$discussion.Headline}}
|
||||
{{- end}}
|
||||
{{- if $discussion.Summary}}
|
||||
Summary: {{$discussion.Summary}}
|
||||
{{- end}}
|
||||
{{- if $discussion.Discussion}}
|
||||
Discussion: {{$discussion.Discussion}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- else -}}
|
||||
No convective outlook data available.
|
||||
{{- end}}
|
||||
|
||||
Reference in New Issue
Block a user